20200929のNode.jsに関する記事は8件です。

「npm does not support Node.js」エラーの解消法

はじめに

npmの実行ができず、調べても中々解消できなかったため、参考までに解消方法を記載しておきます。

事象

エラーメッセージ

npm startを実行すると以下のようなメッセージが出てきました。

npm does not support Node.js v12.18.3

原因

npmとNode.jsのバージョンが合っていない。
npmが古いようです。

試したこと

Node.jsをアンインストールして再インストール

  1. コントロールパネル > すべてのコントロールパネル > プログラムと機能
    「Node.js」を右クリックしてアンインストール
  2. Node.jsのサイトからインストーラーを落としてきて再インストール。

npmをアンインストールして再インストール

  1. 以下のコマンドでnpmをアンインストール

    npm uninstall -g npm
    
  2. 以下のコマンドで最新版をインストール

    npm install -g npm@latest
    

    →最新版のインストールで同じくnot supportのエラーが発生。

解決方法

  1. C:\Users\<ユーザー名>\AppData\Roaming\に移動
  2. npm関連フォルダ削除(安全のため、フォルダ名変更するだけでも可)
    削除対象:「npm」フォルダ、「npm-cache」フォルダ
  3. npm最新バージョンをインストール

    npm install-g npn@latest
    

これで最新版のnpmがインストールされ、npm startも実行できました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

LINEボットでゲームブックを作ってみた

最近LINEボットを扱っていなかったので、久しぶりに動かしてみました。
題材にしたのは「ゲームブック」です。

ゲームブックを知らない方もいるかもしれないので、補足すると、小説のような読み物ではあるのですが、途中で選択肢が出てきて、そのときの状況を読者が判断して適切な選択肢を繰り返し選択していくと、うまくいけばゴール、そうでない場合は途中で終了となります。面白いのが、選択肢の選択は、指定されたページに移動することであって、ページ番号は意味のない数字なので、ちゃんと順番に選択していかないと、ゴールにはたどり着きません。ゴールのページがどこにあるのかわからないからです。

今回は、これをLINEのチャットで実現します。
1チャット1ページで、チャットの内容から判断して適切な次の選択肢を選択すると、次のページの内容がチャットで返ってきます。これを繰り返すわけです。

チャットは、基本的に、「テキスト」と「次に進む選択肢」からなります。
で、せっかく、インタラクティブなLINEチャットを使うので、以下の機能を付けています。

  • チャットに画像ファイルを張り付けられます。さらに、複数の登場人物を指定することで、背景に人物を重ね合わせて表示させました。
  • 音声ファイルを付けられるようにしました。チャットの表示に合わせて、音声再生することで、臨場感が増します。
  • 複数のページにまたがって、フラグを保持するようにしました。特定のページにたどり着くことでフラグを得たり、フラグを失ったりします。本ゲームブック内では持ち物と言っています。
  • 持ち物の状態によって、次の選択肢を追加したり削除したり、画像に重ね合わせる登場人物を追加したり削除したりできるようにしました。

あとは、シナリオファイルにページの内容や次の選択肢を書いていけばできあがります。
シナリオファイルを、JSONファイルで記述します。後でフォーマットを示しておきます。

シナリオファイルには、複数のシーンを配列で記述します。このシーンが、1ページに相当します。
最初のシナリオから別のシナリオに移動することもできます。チャプターの切り替えのようなもので、新しいシーンになったら、持ち物はクリアされます。なので、もし行き詰まってやり直しする場合も、シナリオ単位で管理しているため、シナリオの最初に戻れるようにしています。

以下が、実際にシナリオを組んでみたときのものです。

image.png

image.png

image.png

GitHubにソースコード一式上げておきます。

pururuba/LinebotGamebook
 https://github.com/poruruba/LinebotGamebook

(2020/9/30 修正)
テンプレートメッセージは制約がきついので、Flex Messageに変えました。

シナリオファイルフォーマット

以下、例示とともに、説明を付記しておきます。

{
  "title": "王様に会いに行く",  // (任意) シナリオの名前
  "scene":[
    {
      "id": "0", // (必須) シーンの識別子。シナリオの最初は必ず "0" から始まる
      "title": "序章:始まりの街", // (任意) シーンのタイトル。チャットに太字で表示される。
      "text": "目覚めると、見知らぬ城下街に立っていた。ポケットに金貨5枚。こういう時は、まずは王様に会いに行くべきだろう。", // (必須) チャットに表示されるテキスト。
      "image": { // (任意) 表示する画像ファイル
        "background": "公園", // (必須) 背景となる画像名。png形式
        "composite": [ // (任意) 重ね合わせる画像。出現順で重ね合わせる。
          {
            "name": "跳ねてる子", // (必須) 重ね合わせる画像名。png形式。
            "have": ["アイス"], // (任意) いずれの持ち物も所持していないと画像が重ね合わされない
            "nothave": ["金貨5枚"] // (任意) いずれか持ち物を所持しいると画像が重ね合わされない
          }
        ]
      },
      "audio": { // (任意) 再生する音声ファイル
        "name": "街の中心", // (必須) 再生する音声。m4a形式
        "have": ["アイス"], // (任意) いずれの持ち物も所持していないと音声が再生されない
        "nothave": ["金貨5枚"] // (任意) いずれか持ち物を所持しいると音声が再生されない
      },
      "selection": [ // (任意) 次の選択肢
        {
          "type": "scene", // (任意)次の選択肢の区別。"scene"か"scenario"。未指定のばあいは、"scene"
          "id": "1", // (必須)次の選択肢の識別子。type="scene"の場合はシーンの識別子、type="scenario"の場合はシナリオ名。json形式。
          "title": "街の中心に行く", // (必須)次の選択肢の表示文字
          "have": ["アイス"], // (任意) いずれの持ち物も所持していないと選択肢が表示されない
          "nothave": ["金貨5枚"] // (任意) いずれか持ち物を所持しいると選択肢が表示されない
        }
      ],
      "acquire": ["アイス"], // (任意) 本シーンにたどり着いたときに獲得する持ち物
      "lost": ["金貨5枚"] // (任意) 本シーンにたどり着いたときに失う持ち物
    }
  ]
}

ちょっと補足です。

  • idが、ページ番号に相当します。
  • textが各ページの表示文です。
  • selectionが、次に進むべきページの選択肢です。
  • haveやnothaveがありますが、そのときの持ち物の状態によって、有効化したり無効化したりするためのものです。
  • acquireやlostが、そのページにたどり着いたときの持ち物を獲得したり失ったりするためのものです。

シナリオファイル中に出てくる画像や音声名に対応するファイル名は、以下に従う必要があります。

シナリオファイル
 ファイル名:シナリオ名.json
 形式:JSON形式

画像ファイル
 ファイル名:画像名.png
 形式:PNG

音声ファイル
 音声名.m4a
 形式:AAC

格納場所は、AWSを想定した場合と、ローカル実行を想定した場合の2種類に対応できるようにしました。

AWSを想定した場合
 シナリオファイル、画像ファイル、音声ファイル:S3
 ロジック(Node.js):Lambda+API Gateway
 ユーザごとの状態:DynamoDB

ローカル実行を想定した場合
 シナリオファイル、画像ファイル、音声ファイル:ファイル
 ロジック(Node.js):express
 ユーザごとの状態:ファイル(JSON)

LINEチャットのメッセージの形式

LINEチャットの返信には、内部では、LINEのFlex Messageの機能を使っています。Flex Message Simulatorのおかげで、GUIでテンプレートを作れます。

Flex Message
 https://developers.line.biz/ja/reference/messaging-api/#flex-message
Flex Message Simulator
 https://developers.line.biz/flex-simulator/

ロジック

入り口として2つのエンドポイントがあります。

・linebotエンドポイント
  LINEでユーザがチャットするとそれを受け取ってシナリオに沿ったページのチャットを返します。
・linebot_image
  画像の重ね合わせを処理し、重ね合わせた結果をmimetype=image/png のバイナリ形式で返します。

linebotエンドポイント

LINEに文字を入力すると、以下のところが呼ばれます。

 app.message(async (event, client) =>{

そこらへんのLINEの細かな処理は、line-utils.js にまとめておきました。

linebot/index.js
'use strict';

const IMAGE_URL_BASE = "【linebot_imageのエンドポイントURL】";
const AUDIO_URL_BASE = "【音声ファイルの公開URL】";

const line = require('@line/bot-sdk');
const mm = require('music-metadata');

const HELPER_BASE = process.env.HELPER_BASE || '../../helpers/';

const config = {
  channelAccessToken: process.env.LINE_CHANNEL_ACCESS_TOKEN,
  channelSecret: process.env.LINE_CHANNEL_SECRET,
};
const LineUtils = require(HELPER_BASE + 'line-utils');
const app = new LineUtils(line, config);

const DEFAULT_SCENARIO = process.env.DEFAULT_SCENARIO || 'scenario0';
const DEFAULT_SCENE = process.env.DEFAULT_SCENE || '0';

/* S3用 */
const SCENARIO_BUCKET = process.env.SCENARIO_BUCKET || 'gamebook';
const SCENARIO_OBJECT_BASE = 'scenario/';
const AUDIO_BUCKET = process.env.AUDIO_BUCKET || 'gamebook';
const AUDIO_OBJECT_BASE = 'audio/';

/* ファイル用 */
const SCENARIO_FILE_BASE = './public/gamebook/scenario/';
const AUDIO_FILE_BASE = './public/gamebook/audio/';
const STATE_FILE_BASE = './data/gamebook/';
const fs = require('fs').promises;

const TABLE_NAME = "gamebook";

const AWS = require("aws-sdk");
AWS.config.update({
  region: "ap-northeast-1",
});
const docClient = new AWS.DynamoDB.DocumentClient({
});
var s3  = new AWS.S3({
});

async function load_scenario(name){
/*
  // S3用
  var param_get = {
    Bucket: SCENARIO_BUCKET,
    Key: SCENARIO_OBJECT_BASE + name + ".json"
  };
  var image = await s3.getObject(param_get).promise();
  return JSON.parse(image.Body.toString());
*/
  // ファイル用
  var buffer = await fs.readFile(SCENARIO_FILE_BASE + name + '.json', "utf-8");
  return JSON.parse(buffer.toString());
}

async function load_audio(name){
/*
  // S3用
  var param_get = {
    Bucket: AUDIO_BUCKET,
    Key: AUDIO_OBJECT_BASE + name + ".m4a"
  };
  var image = await s3.getObject(param_get).promise();
  return image.Body;
*/
  // ファイル用
  return await fs.readFile(AUDIO_FILE_BASE + name + '.m4a');
}

async function load_status(userid){
/*
  // S3用
  var params_get = {
    TableName: TABLE_NAME,
    Key: {
      userid: userid,
    }
  };
  var result = await docClient.get(params_get).promise();
  return result.Item;
*/
  // ファイル用
  try{
    var status = await fs.readFile(STATE_FILE_BASE + userid + '.json', 'utf8');
    return JSON.parse(status);
  }catch(error){
    return undefined;
  }
}

async function create_status(userid, scenario, scene){
  return {
    userid: userid,
    scenario: scenario,
    scene: scene,
    turn: 0,
    items: [],
  };
}

async function insert_status(status){
/*
  // S3用
  var params_put = {
    TableName: TABLE_NAME,
    Item: status
  };
  return await docClient.put(params_put).promise();
*/
  // ファイル用
  return fs.writeFile(STATE_FILE_BASE + status.userid + '.json', JSON.stringify(status), 'utf8');
}

async function update_status(status){
/*
  // S3用
  var params_update = {
    TableName: TABLE_NAME,
    Key: {
      userid: status.userid
    },
    ExpressionAttributeNames: {
      '#attr1': 'scenario',
      '#attr2': 'scene',
      '#attr3': 'items',
      '#attr4': 'turn',
    },
    ExpressionAttributeValues: {
      ':attrValue1': status.scenario,
      ':attrValue2': status.scene,
      ':attrValue3': status.items,
      ':attrValue4': status.turn,
    },
    UpdateExpression: 'SET #attr1 = :attrValue1, #attr2 = :attrValue2, #attr3 = :attrValue3 , #attr4 = :attrValue4',
    ConditionExpression: "attribute_exists(userid)",
    ReturnValues:"ALL_NEW"
  };
  return await docClient.update(params_update).promise();
*/
  // ファイル用
  return insert_status(status);
}

function add_item(item_list, item){
  if( item_list.indexOf(item) >= 0 )
    return false;

  item_list.push(item);
  return true;
}

function remove_item(item_list, item){
  var index = item_list.indexOf(item);
  if( index < 0 )
    return false;

  item_list.splice(index, 1);
  return true;
}

function has_item(item_list, item){
  return ( item_list.indexOf(item) >= 0 );
}

function check_condition(items, have, nothave){
  var condition = true;

  // haveのアイテムの所持確認
  if( have ){
    have.forEach( item => {
      if( !has_item(items, item ) ){
        condition = false;
        return;
      }
    });
  }
  // nothaveのアイテムの非所持確認
  if( nothave ){
    nothave.forEach( item => {
      if( has_item(items, item ) ){
        condition = false;
        return;
      }
    });
  }

  return condition;
}

app.message(async (event, client) =>{
  try{
    console.log(event);

    var cmd_processed = false;

    // userIdのステータスをDBから取得
    var status = await load_status(event.source.userId);
    if( !status ){
      // 初めてのuserIdの場合はステータスを生成
      status = await create_status(event.source.userId, DEFAULT_SCENARIO, DEFAULT_SCENE);
      // DBに同期
      await insert_status(status);
    }

    if( event.message.text == 'ヘルプ'){
      // コマンド:ヘルプ
      var message = {
        type: "text",
        text: "コマンドリスト\n  リタイア:最初からやり直し\n  リセット:今のシナリオの最初に戻る\n  リロード:再表示\n  持ち物:持ち物の確認\n  ステータス:シナリオ名の確認",
        quickReply: {
          items: [
            {
              type: 'action',
              action: {
                type: "message",
                label: "リセット",
                text: "リセット"
              }
            },
            {
              type: 'action',
              action: {
                type: "message",
                label: "リロード",
                text: "リロード"
              }
            },
            {
              type: 'action',
              action: {
                type: "message",
                label: "持ち物",
                text: "持ち物"
              }
            },
            {
              type: 'action',
              action: {
                type: "message",
                label: "ステータス",
                text: "ステータス"
              }
            },
          ]
        }
      };
      return client.replyMessage(event.replyToken, message);
    }

    var com_list = event.message.text.split(' ');

    switch(com_list[0]){
      case 'リタイア':{
        // コマンド:リタイア
        status = await create_status(event.source.userId, DEFAULT_SCENARIO, DEFAULT_SCENE);
        cmd_processed = true;
        break;
      }
      case 'リセット':{
        // コマンド:リセット
        status.scene = DEFAULT_SCENE;
        status.items = [];
        cmd_processed = true;
        break;
      }
      case 'リロード':{
        // コマンド:リロード
        cmd_processed = true;
        break;
      }
      case 'scenario':{
        // シナリオの変更
        if( parseInt(com_list[2]) != (status.turn + 1) )
          throw "turn is invalid";
        status.scenario = com_list[1];
        status.scene = DEFAULT_SCENE;
        cmd_processed = true;
        break;
      }
      case 'scene':{
        // シーンの変更
        if( parseInt(com_list[2]) != (status.turn + 1))
          throw "turn is invalid";
        status.scene = com_list[1];
        cmd_processed = true;
        break;
      }
    }

    // ターン番号をインクリメント
    status.turn++;

    // 現在のシナリオを取得
    const scenario = await load_scenario(status.scenario);
    if( !scenario )
      throw "scenario not found";

    // 現在のシーンを取得
    var scene = scenario.scene.find(item => item.id == status.scene );
    if( !scene )
      throw "scene not found";

    if( event.message.text == '持ち物' || event.message.text == 'もちもの'){
      // コマンド:持ち物
      var message = {
        type: "text",
        text: "現在の持ち物"
      };
      if( status.items.length > 0 ){
        status.items.forEach( item =>{
          message.text += "\n" + item;
        });
      }else{
        message.text += "はありません";
      }
      cmd_processed = true;
      return client.replyMessage(event.replyToken, message);
    }else
    if( event.message.text == 'ステータス'){
      // コマンド:ステータス
      var message = {
        type: "text",
        text: "シナリオ名:" + scenario.title + "\nシーン番号:" + status.scene,
      };
      cmd_processed = true;
      return client.replyMessage(event.replyToken, message);
    }

    var messages = [];

    if(!cmd_processed ){
      // 不明なコマンド
      var message = {
        type: "text",
        text: "不明なコマンド",
      };
      messages.push(message);
    }

    // 獲得アイテムの処理
    if(scene.acquire && scene.acquire.length > 0){
      scene.acquire.forEach(item => add_item(status.items, item));
      var message = {
        type: "text",
        text: scene.acquire.join('') + "を手に入れた"
      }
      messages.push(message);
    }

    // ロストアイテムの処理
    if( scene.lost && scene.lost.length > 0){
      scene.lost.forEach(item => remove_item(status.items, item));
      var message = {
        type: "text",
        text: scene.lost.join('') + "を失った"
      };
      messages.push(message);
    }

    // メインダイアログ
    var flex = {
      type: "flex",
      altText: scene.text,
      contents: {
        type: "bubble",
        size: "kilo",
        body: {
          type: "box",
          layout: "vertical",
          contents: [],
        },
        footer:{
          type: "box",
          layout: "vertical",
          contents:[],
          flex: 0
        }
      }
    };

    if( scene.image ){
      // 画像が指定されていた場合
      var image_url = IMAGE_URL_BASE + scene.image.background;
      // 画像合成が指定されていた場合
      if(scene.image.composite ){
        scene.image.composite.forEach( select => {
          // アイテムの所持・非所持確認
          var condition = check_condition(status.items, select.have, select.nothave );
          if( !condition )
            return;

          // 合成画像の指定追加
          image_url += '-' + select.name;
          if( select.position != undefined )
            image_url += '_' + select.position;
        });
      }
      flex.contents.hero = {
        type: "image",
        url: encodeURI(image_url),
        size: "full",
        aspectRatio: "20:13",
        aspectMode: "fit"
      };
    }

    if( scene.title ){
      // タイトルが指定されていた場合
      flex.contents.body.contents.push({
        type: "text",
        wrap: true,
        text: scene.title,
        weight: "bold",
        size: "md"
      });
    }

    // テキストの設定
    flex.contents.body.contents.push({
      type: "text",
      wrap: true,
      size: "sm",
      text: scene.text
    });

    // 次の選択肢
    if( scene.selection){
      scene.selection.forEach( select =>{
        // アイテムの所持・非所持確認
        var condition = check_condition(status.items, select.have, select.nothave );
        if( !condition )
          return;

        // 選択肢の追加
        var type = "scene";
        if( select.type )
          type = select.type;

        flex.contents.footer.contents.push({
          type: "button",
          style: "link",
          height: "sm",
          action:{
            type: "message",
            label: select.title + '(' + select.id + ')',
            text: type + " " + select.id + " " + (status.turn + 1)
          }
        })
      });
    }
    if( flex.contents.footer.contents.length == 0 ){
      flex.contents.footer.contents.push({
        type: "button",
        style: "link",
        height: "sm",
        action:{
          type: "message",
          label: "シナリオの最初に戻る",
          text: "リセット"
        }
      });
      flex.contents.footer.contents.push({
        type: "button",
        style: "link",
        height: "sm",
        action:{
          type: "message",
          label: "最初から始める",
          text: "リタイア"
        }
      });
    }
    messages.push(flex);

    if( scene.audio ){
      // 音声ファイルが指定されていた場合
      // アイテムの所持・非所持確認
      var condition = check_condition(status.items, scene.audio.have, scene.audio.nothave );
      if( condition ){
        var audio_buffer = await load_audio(scene.audio.name);
        var metadata = await mm.parseBuffer(audio_buffer, "audio/aac")
        var message = {
          type: "audio",
          originalContentUrl: encodeURI(AUDIO_URL_BASE + scene.audio.name + '.m4a'),
          duration: Math.floor(metadata.format.duration * 1000) 
        };
        messages.push(message);
      }
    }

    //userIdのステータスをDBに更新
    console.log(status);
    update_status(status);

    // メッセージの一括送信
    console.log(messages);
    console.log(JSON.stringify(messages));
    return client.replyMessage(event.replyToken, messages);
  }catch(error){
    console.error(error);
    var message = {
      type: "text",
      text: error.toString()
    };
    return client.replyMessage(event.replyToken, message);
  }
});

exports.fulfillment = app.lambda();

linebot_imageエンドポイント

背景画像と人物画像を動的に重ね合わせます。
sharpというnpmモジュールを使いました。

linebot_image/index.js
'use strict';

const sharp = require('sharp');

const HELPER_BASE = process.env.HELPER_BASE || '../../helpers/';
const BinResponse = require(HELPER_BASE + 'binresponse');

// S3用
const IMAGE_BUCKET = process.env.IMAGE_BUCKET || 'gamebook';
const IMAGE_OBJECT_BASE = 'images/'

var AWS = require('aws-sdk');
AWS.config.update({
  region: "ap-northeast-1",
});
var s3  = new AWS.S3({
  // 必要に応じて
});

// ファイル用
const IMAGE_FILE_BASE = './public/gamebook/images/';
const fs = require('fs').promises;

async function load_image(name){
/*
  // S3用
  var param_get = {
    Bucket: IMAGE_BUCKET,
    Key: IMAGE_OBJECT_BASE + name + ".png"
  };
  var image = await s3.getObject(param_get).promise();
  return image.Body;
*/
  // ファイル用
  return fs.readFile(IMAGE_FILE_BASE + name + ".png");
}

exports.handler = async (event, context, callback) => {
  console.log(event);

  var paths = decodeURIComponent(event.path).split('/');
  var words = paths[2].split('-');

  const image_buffer = await load_image(words[0]);
  const image = sharp(image_buffer);
  const image_meta = await image.metadata();
  var width = image_meta.width;
  var unit = width / 12.0;

  var list = [];
  for( var i = 1 ; i < words.length ; i++ ){
    var params = words[i].split('_');
    const add_buffer = await load_image(params[0]);
    const add = sharp(add_buffer);
    const add_meta = await add.metadata();

    var position = ( params.length > 1 ) ? parseInt(params[1]) : 6;
    var left = Math.floor(position * unit - unit / 2 - add_meta.width / 2);

    list.push({
      input: add_buffer,
      left : (left < 0) ? 0 : left,
      top: (add_meta.height < image_meta.height ) ? (image_meta.height - add_meta.height) : 0,
    })
  }

  image.composite(list);

  return image.toBuffer()
  .then(buffer =>{
    return new BinResponse('image/png', buffer);
  });
};

サンプルシナリオ

public/scenarioにサンプルシナリオを置いておきました。

とりあえず、著作権の関係で、画像ファイルや音声ファイルの除いた形で、scenario0.jsonとscenario1.jsonを用意しました。
画像が用意できるようであれば、「画像有」フォルダにあるシナリオを参考にしてみてください。「template」フォルダにあるのは、シナリオファイルのテンプレートです。

LINEボットのセットアップ

GitHubからダウンロードして、expressを起動しておきます。

> unzip LinebotGamebook_master.zip
> cd LinebotGamebook_master
> npm install
> mkdir data
> mkdir data/gamebook
> node app.js

LINEボットとして動かすためには、LINEから払いだされる各種シークレットが必要です。

LINE Developers
 https://developers.line.biz/ja/

チャネルの種類は、Messaging APIでチャネルを作成し、チャネル基本情報にある「チャネルシークレット」、Messaging API設定にある「チャネルアクセストークン(長期)」を生成し、メモっておきます。
また、Messaging APIにあるWebhook設定において、立ち上げるexpressサーバのURLまたはAWS API GatewayのURLを指定し、Webhookの利用 をOnにします。ついでに、「応答メッセージ」は無効にしておきます。

補足

LINEチャットのテンプレートメッセージの機能を使っているのですが、ちょっと1文字間違えただけでエラーとなり、どこが間違っているのか教えてくれないので、エラー発生時には苦労しました。。。

お礼

今回は著作権の関係で、GitHubにはおけませんでしたが、以下の画像ファイルや音声ファイルを利用させていただきました。ありがとうございました。

<画像ファイル>

<音声ファイル>

おわりに

シナリオエディタもあるとよさそうです。

以上

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

nodenvをupdateして新しいバージョンのNode.jsをインストールする

nodenvで欲しいバージョンのnodeが見つからない……

プロジェクトで指定されたバージョンのNode.jsをインストールしようとして

nodenv install --list

とうっても、欲しいバージョンがリストに出てこないときってありませんか?

どうやらnodenvはインストール時に登録されているバージョンしか表示してくれないようです。

調べてみても、nodenvを消してから再インストールするみたいな、ええっ?という情報が多いです。

nodenvをアップデートする一番簡単な方法

anyenvのプラグインであるanyenv-updateを使いましょう。

※ そもそもanyenv使っていない人は、こちらなどが参考になります。anyenv、オススメです。

anyenv-updateのインストール

anyenvのルートディレクトリ(デフォルトでは~/.anyenv)の下に、pluginsディレクトリを作り、リポジトリをクローンします。

mkdir -p $(anyenv root)/plugins
git clone https://github.com/znz/anyenv-update.git $(anyenv root)/plugins/anyenv-update

nodenvのアップデート

コマンドをうつだけです。かんたんですね?

anyenv update

※ anyenvで管理しているnodenv以外のツールもアップデートしてくれます

anyenv使いたくないよって人は……

手動でリストをアップデートする

公式のREADMEに方法が載っていました。

node-buildというデフォルトのプラグインのリポジトリを最新にすればよいらしいです。

cd ~/.nodenv/plugins/node-build
git pull

nodenv-updateを使う

nodenv-updateというプラグインがあるようです。

# インストール
mkdir -p "$(nodenv root)"/plugins
git clone https://github.com/nodenv/nodenv-update.git "$(nodenv root)"/plugins/nodenv-update

# アップデート
nodenv update

おわりに

参考になれば幸いですっ。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Mochaで実行時に特定のテストをスキップする

こんな感じで this.skip() を使えばOK。

const assert = require('assert');
const os = require('os');

suite('テスト', function(){
    test('Winの場合はスキップ', function(){
        if (os.platform() == 'win32') this.skip();
        assert.ok(1);
    });
});

this が必要なので、アロー関数内のテストだとダメ。関数で囲うべし。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

6. Koa2を使ってWebページを作成しよう ~validatonで検証~

記事一覧

概要

今回は、入力値のバリデーションの実装を行います。
また、前回の続きとして進めて行くので、上手くいかない場合は以前の記事をご覧ください。

バリデーション

バリデーションは値のフォーマットが正しい形であるかの確認をする処理で、今回は入力必須や文字数制限、メールアドレス形式であるかなどの観点で値の検証を行っていきます。
一例として、ユーザーから値を受け取って処理を行うプログラムでは、ユーザーが必ずしも正しいフォーマットでデータを入力するとは限らないため、フォーマットの確認が必要になります。

*バリデーションにはこちらのパッケージを利用します。
https://github.com/skaterdav85/validatorjs

以下のコマンドをコンソールで実行してください。
npm install validatorjs

DBの作成

phpMyAdminで以下のSQLを実行してください。
別のツールでDBを操作できる場合は、そちらでも問題ありません。

create table koa2_test.validate_user
(
   user_id   int auto_increment
       primary key,
   user_name varchar(20)  not null,
   password  varchar(60) not null,
   mail      varchar(255) not null
);

DBの各列で指定されている条件をまとめると、以下のようになります。

  • user_id: 主キー制約。自動採番。
  • user_name: 最大20文字。NOTNULL。
  • password: 最大60文字。NOTNULL。
  • mail: 最大255文字。NOTNULL。

また、以下の条件も考慮します。

  • user_idは自動採番で、バリデーションの対象とする必要はない
  • mailはメールアドレスの形式である事も検証する
  • パスワードはハッシュ化前文字列の最大を128文字とする。

よって、必要となる検証項目は以下のようになります。

  • user_name: 20文字以下であること。値が空でない事。
  • password: 128文字以下であること。値が空でない事。
  • mail: 255文字以下であること。値が空でない事。メールアドレスの形式である事。

validatorで入力値の検証

/router/validator-signup.jsを作成し、以下の記述を追加してください。

validator-signup.js
const Router = require('koa-router');
const router = new Router();
const connection = require('../app/db');
const bcrypt = require('bcrypt');
const validator = require('validatorjs');

router.get('/validator-signup', async (ctx) => {
    await ctx.render('validator-signup');
});

router.post('/validator-signup', async (ctx) => {
    // POSTパラメータを取得
    let userName = ctx.request.body['name'];
    let password = ctx.request.body['password'];
    let mail = ctx.request.body['mail'];

    // 入力値の検証
    let validationResult = await validation([userName, password, mail]);

    // 入力値拒否時、登録処理を行わずにエラーメッセージを表示する
    if (validationResult.result === false) {
        await ctx.render('validator-signup', validationResult);
    } else {
        // パスワードをハッシュ化
        const salt = bcrypt.genSaltSync();
        const hashPassword = bcrypt.hashSync(password, salt);

        // DBにアカウント情報を登録
        let signupSQL = 'INSERT INTO validate_user (user_name, password, mail) VALUES (?, ?, ?)';
        await connection.query(signupSQL, [userName, hashPassword, mail]);

        await ctx.render('validator-signup');
    }
});

module.exports = router;

大部分は前回の記事で作成したアカウント登録処理と同じものですが、入力値の検証を行う処理を追加しています。
次に、入力値の検証を行うためのvalidation関数を作成します。

validation関数の作成

/router/validator-signup.jsの最後に以下の記述を追加してください。

validator-signup.js
async function validation(data) {
    // ユーザーの入力値をvalidatorjsへの入力形式である連想配列に変換。
    // ここで指定しているkey値がrulesやerrorMessageの条件部分でも利用される。
    let requests = {
        userName: data[0],
        password: data[1],
        mail: data[2],
    }

    // それぞれの値に対して掛ける制約を指定している。
    // required: 文字列が0文字でないことを確認
    // max: 最大文字数以下であることを確認
    // email: メールの形式であることを確認
    let rules = {
        userName: 'required|max:20',
        password: 'required|max:128',
        mail: 'required|max:255|email',
    };

    // '条件.検証項目'の形で、検証項目が条件を満たしていない場合の
    // エラーメッセージを指定している。
    // ここで指定していないエラーについては、
    // デフォルトで設定されている英語のメッセージが返される。
    let errorMessage = {
        'required.userName': 'USER NAMEは必須項目です',
        'required.password': 'PASSWORDは必須項目です',
        'required.mail': 'MAILは必須項目です',
        'max.userName': 'USER NAMEは20文字以下で入力して下さい',
        'max.password': 'PASSWORDは128文字以下で入力して下さい',
        'max.mail': 'MAILは255文字以下で入力して下さい',
        'email.mail': 'MAILはメールアドレスの形式で入力して下さい'
    }

    // validator(検証する値, 検証する条件, 拒否時のメッセージ) の形で、
    // validatorのインスタンスを生成。
    let signupValidator = new validator(requests, rules, errorMessage);

    let result = {
        error: {},
        result: false
    };

    // 値の検証を実行して、成功時とエラー時で処理を振り分ける。
    // checkAsync() の第一引数は成功時の関数を、第二引数は失敗時の関数を指定する。
    await signupValidator.checkAsync(() => {
        // Success時の処理
        result.result = true;
    }, () => {
        // Error時の処理
        result.result = false;

        // errors.first(‘userName’)では、userNameにエラーが発生している場合、
        // 発生したエラーに応じて、validatorインスタンス生成時に
        // 第三引数で指定したメッセージを取得している。
        // 取得した値はresult変数に格納して、関数の最後でreturnしている。
        if (signupValidator.errors.first('userName')) {
            result.error.userName = signupValidator.errors.first('userName');
        }
        if (signupValidator.errors.first('password')) {
            result.error.password = signupValidator.errors.first('password');
        }
        if (signupValidator.errors.first('mail')) {
            result.error.mail = signupValidator.errors.first('mail');
        }
    })

    return result;
}

*上記以外の制約についてはこちらのページを参照。
https://github.com/skaterdav85/validatorjs

入力フォームを作成

/view/validator-signup.ejsを作成し、以下の記述を追加してください。

validator-signup.ejs
<h1>アカウント登録</h1>
<% if(typeof error !== 'undefined'){ %>
    <% for(let key in error) { %>
    <p style="color: red"><%= error[key] %></p>
    <% } %>
<% } %>
<form action="validator-signup" method="post">
    USER NAME <input type="text" name="name"><br>
    PASSWORD <input type="password" name="password"><br>
    MAIL <input type="email" name="mail"><br>
    <input type="submit" value="登録"><br>
</form>

ルーティングを設定

/index.jsに以下の記述を追加してください。

index.js
const validatorSignupRouter = require('./router/validator-signup');
app.use(validatorSignupRouter.routes());
app.use(validatorSignupRouter.allowedMethods());

ページの遷移を修正

/view/login.ejsを以下のように修正してください。

login.ejs
<h1>ログイン</h1>
<form action="login" method="post">
   USER NAME <input type="text" name="name" placeholder="userと入力"><br>
   PASSWORD <input type="password" name="password" placeholder="userと入力"><br>
   <input type="submit" value="ログイン"><br>
</form>

<!--validator-signupに遷移するように修正-->
<a href="validator-signup">アカウント登録</a>
<% if(typeof loginResult !== "undefined"){ %>
   <br><br>
   <%= loginResult %>
   <br><br>
   <a href="index">トップへ</a>
<% } %>

ログイン認証で参照するDBを変更

ログイン時、検証を行なっているSQL文を修正します。
/router/login.js のSQL文を以下のように修正してください

login.js
// DBからuserNameが一致するデータを検索
let loginSQL = 'SELECT user_id, password, user_name FROM validate_user WHERE user_name = ?';

validationの表示結果

下記のコマンドを実行して、以下のURLにアクセスしてください。
npm start

http://localhost:5000/validator-signup

validation-signup.png

最後に

以上でvalidatorjsを利用した入力値の検証は完成です。
次回はセッションを用いたログイン状態の管理を実装してきます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

firebase login で "Assertion failed: new_time >= loop->time, file src\win\timer.c, line 37"と出てコケる

firebase login で "Assertion failed: new_time >= loop->time, file src\win\timer.c, line 37"と出てコケる

Intelのice lake chipを搭載したwindows 10のパソコンで発生する、nodejs内のバグが原因のようです。

node v14.6.0で修正のパッチがあたっているので、それ以上のバージョンにアップデートすれば解決します。

Github
Assertion failed: new_time >= loop->time #1633
https://github.com/libuv/libuv/issues/1633

Newest Node release, Node v14.6.0 contains the fix for this. Give it a spin.

nodeのバージョンを変えたくない人は、好きなバージョンに下記のパッチを自分で当ててビルドすれば動くようです。
https://github.com/libuv/libuv/commit/796744869669842bd5405a71de8ba60b1556fc24

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

firebase-toolsを8系にバージョンアップしたときにwebpackのビルドがコケた話

経緯

個人開発でフロントエンドにVue.js、バックエンドにFirebaseを利用したアプリケーションを作っています。(気が向いたときに作業しているので中々進みませんが。。)

最近ふと思い立ってnpmパッケージのバージョンを上げようと思ったところ、firebase-toolsのバージョンアップに苦しんだので供養しておきます。

※バージョンアップ前は v7.3.2 でした。
firebase-toolsv8.0.0のリリースは2020年4月1日なので、投稿時点で約半年経過していることになります。。

firebase-tools の使いどころ

firebase-toolsはコマンドラインツールとして、プロジェクトの設定やデプロイなどを実行できます。

しかしコマンドラインから操作するだけではなく、webpackでFirebaseのプロジェクトを切り替える方法で紹介されているようにNode.jsのコードから利用することも可能で、webpack の loader の処理として利用することができます。

firebase-config-loader.js
// https://medium.com/google-cloud-jp/webpack-multiproject-firebase-55d5bd8beb5d より引用

const firebaseTools = require('firebase-tools')
function FirebaseConfigLoader () {
  const callback = this.async()
  firebaseTools.setup.web().then(config => {
    callback(null, JSON.stringify(config))
  }).catch(err => callback(err))
}
module.exports = FirebaseConfigLoader

firebase-tools v8.0.0 で何があったか

詳細はリリース情報を見ていただくとして、Breaking Change がいくつかあるのですが、今回苦しめられたのは以下のものです。

BREAKING: Removes firebase setup:web command (replacement: firebase apps:sdkconfig web).

読んでわかる通りsetup:webコマンドを削除したのでapps:sdkconfig webを使ってね、と指示されています(非推奨になったのはv7.3.0-2019年8月28日リリース)。
コマンドラインでは使用したことはありませんが、webpack の loader で使っているので影響がありました。

最初の対応

最初はただ単純に、指示されている通りapps:sdkconfig webで呼び出すように変更しました。

 const firebaseTools = require('firebase-tools')
 function FirebaseConfigLoader () {
   const callback = this.async()
-  firebaseTools.setup.web().then(config => {
+  firebaseTools.apps.sdkconfig("web", "", {}).then(config => {
     callback(null, JSON.stringify(config))
   }).catch(err => callback(err))
 }
 module.exports = FirebaseConfigLoader

しかしコードを変更したのちビルドすると、以下のようなエラーを吐いて無事コケてしまいました。

 ERROR  Failed to compile with 1 errors

 error  in ./firebase-config.json

Module build failed (from ./firebase-config-loader.js):
FirebaseError: There are no WEB apps associated with this Firebase project
    at /hoge/node_modules/firebase-tools/lib/commands/apps-sdkconfig.js:25:19
    at Generator.next (<anonymous>)
    at /hoge/node_modules/firebase-tools/lib/commands/apps-sdkconfig.js:8:71
    at new Promise (<anonymous>)
    at __awaiter (/hoge/node_modules/firebase-tools/lib/commands/apps-sdkconfig.js:4:12)
    at selectAppInteractively (/hoge/node_modules/firebase-tools/lib/commands/apps-sdkconfig.js:23:12)
    at /hoge/node_modules/firebase-tools/lib/commands/apps-sdkconfig.js:69:39
    at Generator.next (<anonymous>)
    at fulfilled (/hoge/node_modules/firebase-tools/lib/commands/apps-sdkconfig.js:5:58)
    at process._tickCallback (internal/process/next_tick.js:68:7)

エラーメッセージから察するにコード修正は間違っていなさそうです。が、念のためコマンドラインからも実行してみたところ、同じエラーメッセージが返ってきたのでコード以外に問題があることがわかりました。

$ npx firebase apps:sdkconfig web

Error: There are no WEB apps associated with this Firebase project

エラーの解消方法

エラーメッセージで色々検索してもピンポイントの記事が見つからず、手当たり次第に探したところ公式のFirebase を JavaScript プロジェクトに追加するを発見しました。ここに記載されている通り、Firebase コンソールから「アプリを登録する」ことで解消できました。

image.png

設定したのちコマンドラインから実行すると確かに config が取得できていることが確認できます。もちろん、ビルドも成功しました。

$ npx firebase apps:sdkconfig web
✔ Downloading configuration data of your Firebase WEB app
// Copy and paste this into your JavaScript code to initialize the Firebase SDK.
// You will also need to load the Firebase SDK.
// See https://firebase.google.com/docs/web/setup for more details.

firebase.initializeApp({
  "projectId": "...",
  "appId": "...",
  "databaseURL": "...",
  "storageBucket": "...",
  "locationId": "...",
  "apiKey": "...",
  "authDomain": "...",
  "messagingSenderId": "..."
});

結論

今回firebase-toolsをv8.0.0にバージョンアップする際にやることは2つありました。

  1. コードの修正
  2. Firebase コンソールから「アプリを登録する」

最後に

Firebase コンソールから「アプリを登録する」ということは、通常 Firebase プロジェクトを作成するときは必ずやっているものなのでしょうか。 :thinking:
実は最近設定できるようになった・するようになった項目、であれば心荒れることなく過ごせそうです。

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

node+dockerでbcryptしたら、エラーおきた

dockerとnodeの環境でbcryptライブラリを使ったときのエラー

dockerでnodeでbcryptを使うとエラーが起きます。
node_modulesをコピーしてしまうとエラーが起きるので、.dockerignoreを使いましょう

dockerignoreを使いましょう

tree
.
├── Dockerfile
├── app.js
├── node_modules
├── package-lock.json
├── package.json
└── .dockerignore ←これを追加
.dockerignore
node_moduels/

ちなみにDockerfileはこんな感じ

FROM node:12
WORKDIR /usr/src/app

COPY package.json ./
COPY package-lock.json ./
RUN npm install

COPY . .

CMD [ "npm", "start" ]
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む