🏋️‍♂️

自分で自分をホスティングするWebアプリケーション2: 実装

2021/06/20に公開

はじめに

こちらの続きです。
https://zenn.dev/ocknamo/articles/23a25ae6f8d9be

本記事ではプロトタイプの実装の解説をします。

完成したプロトタイプがこちらです。

https://selfhostingengine.ipfscrosspoint.net/

なにをしているの?

このアプリケーションはIPFSノード(js-ipfs)を起動しており、そのノードに自分自身のファイルをホストします。

自分自身のファイルはそれ自身のハッシュに基づくID(CID)でIPFS上に保持されています。この値は入力ファイルが1ビットでも変更されると全く異なる値になるため、同じCIDであれば元のデータが同一であることが保証されています。このアプリケーション上では自分自身のCIDを"My CID"に表示しています。

ホストしているということは、IPFSゲートウェイからもアプリケーションを利用することができるはずです。アプリケーションの下部のリンクからゲートウェイを開くと全く同じアプリケーションの画面が別窓で開きます。

画面上の"My CID"の値と、URL欄のサブドメインに表示されるCIDの値を見比べてみてください。全く同じものになっています。この"My CID"はあくまでアプリケーション内で自分のファイルを評価して作成した値で外部の値は使用していません。

また私は他のいかなるIPFSノードにもこのファイルをホストしてはいません。
したがって、ゲートウェイでこのアプリが表示できたということは、自分で自分をIPFS上にホスティングすることに成功したということになります。

詳しいコンセプトなどについては前回の記事をご覧ください。

https://zenn.dev/ocknamo/articles/23a25ae6f8d9be

環境

  • node: v14.17.0
  • npm: 6.14.13
  • ipfs-core: 0.7.1
  • angular-cli: 12.0.4
  • typescript: 4.2.3
  • ブラウザ: google chrome Version 91.0.4472.77 (Official Build) (64-bit)

また動作確認のためにHTTPSで接続できるデプロイする場所が必要です。(dev-serverでは動作確認できません)

準備

プロトタイプ用にAngularのアプリケーションを作成してipfs-coreパッケージを追加します。

npx -p @angular/cli ng new
cd <your-app-name>
rm packcage-lock.json
yarn
yarn add ipfs-core

Angular上でipfsを動かすにはnodeのポリフィルが必要になるのでこちらを参考にwebpackなどの設定を行ってください。
https://zenn.dev/ocknamo/articles/517c8b0de675e3

(追記) Angular12ではtsconfig.jsonの"target"をes2017からes2015に修正しないとビルドがこけるようです。

最上位のコンポーネントを修正してipfs-coreを呼び出してipfsを起動してみます。

// app.component.ts
import { Component, OnInit } from '@angular/core';
import * as ipfs from 'ipfs-core';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit {
  title = 'self-hosting-engine';
  id = ''
  agentVersion = ''

  async ngOnInit(): Promise<void> {
    const node = await ipfs.create();

    const { id, agentVersion } = await node.id();
    this.id = id;
    this.agentVersion = agentVersion;
  }
}

// app.component.html
<div>{{ title }}</div>
<div>ID: {{ id }}</div>
<div>AgentVersion: {{ agentVersion }}</div>

yarn startでdev serverを起動します。

IDとAgentVersionが表示されノードが起動できたことがわかります。

あとはホスティングを行なうserviceとビルド後にメタデータを生成するためのスクリプトを作ります。serviceから説明します。

self-hosting.service

肝心のセルフホスティングを行なう箇所は専用のサービスに切り出してapp.component.tsから呼び出します。

// angular-cli でserviceの雛形を作ってしまうと楽
$ mkdir ./src/app/services
$ cd ./src/app/services
$ npx ng generate service

雛形から作成するとAngularのDIフレームワークに乗っかるように設定してくれるのですが、Angularを知らない人でもわかりやすいようにあえてただのtypescriptのクラスにしました。Angular特有の機能は使っていないので、typescriptが動く環境なら動くはずです。

こちらが全体像になります。この先で個別で説明しているので先にそちらを見てから全体を確認するといいかもしれません。

src/app/services/self-hosting.service.ts
// src/app/services/self-hosting.service.ts
import * as CID from 'cids';
import { ImportCandidateStream } from 'ipfs-core-types/src/utils';
import IPFS from 'ipfs-core/src/components';
import { environment } from 'src/environments/environment';

import pj from '../../../package.json';

interface SelfHostMeta {
  filenames: string[];
}

const DIRECTORY_NAME = pj.name;

/**
 * Host this SPA application on its IPFS node.
 * This service works on https only.
 * Meta file like "appfilelist.json" is needed. It should be generated after application build.
 * Reference: scripts/generate-builded-files-list.sh
 */
export class SelfHostingService {
  /**
   * This is the application root CID.
   * You can use this application via gateway by this CID.
   * eg. https://gateway.ipfs.io/ipfs/<CID>
   */
  directoryCID: CID | undefined;

  private baseUrl = window.location.origin;
  private selfHostMetaPath = '/appfilelist.json';

  constructor(
    private readonly ipfsNode: IPFS
  ) {}

  /**
   * Host this SPA application files on PFS node.
   *
   * @returns Promise<CID | void>
   */
  async host(): Promise<CID | void> {
    if (!environment.production) {
      return;
    }

    // Fetch meta data.
    const res = await fetch(`${this.baseUrl}${this.selfHostMetaPath}`);
    if (!res.ok) {
      throw new Error('No meta file for self host.');
    }
    const meta: SelfHostMeta = await res.json();

    // Fetch application data.
    const files = await this.fetchMyself(meta);

    // Prepare file contents.
    const fileToAdd: ImportCandidateStream = files.map((f) => ({
      path: `/${DIRECTORY_NAME}/${f.name}`,
      content: f.content,
    }));

    // Add all files into IPFS node.
    const added = this.ipfsNode.addAll(fileToAdd, { cidVersion: 1 });

    // Get directory CID.
    for await (const v of added) {
      if (v.path === DIRECTORY_NAME) {
        this.directoryCID = v.cid;
      }
    }

    return this.directoryCID;
  }

  /**
   * Fetch all application file from origin.
   * The meta file should have the information for all the necessary files.
   *
   * @param meta SelfHostMeta
   * @returns Promise<{ name: string; content: Blob}[]>
   */
  private async fetchMyself(
    meta: SelfHostMeta
  ): Promise<{ name: string; content: Blob }[]> {
    const promises = meta.filenames.map((filename) =>
      fetch(`${this.baseUrl}/${filename}`).then((res) => ({
        name: filename,
        content: res,
      }))
    );

    const responses = await Promise.all(promises);
    const src = responses.map((v) =>
      v.content.blob().then((b) => ({ name: v.name, content: b }))
    );
    return Promise.all(src);
  }
}

interface SelfHostMeta

まず、SelfHostMetaというインターフェイスでメタデータの型を定義しています。

interface SelfHostMeta {
  filenames: string[];
}

どのようなデータが入るかというと、以下のようにWebアプリケーションとメタファイルそのもののファイル名です。

{"filenames":["polyfills.xxxxx.js","main.xxxxx.js","styles.xxxxx.css",...<略>..."favicon.ico","index.html","asset/image1.png","appfilelist.json"]}

host method

さて、serviceのメソッドは"host"だけなのでその処理の流れを追っていきます。

dev-serverではメタファイルの作成に対応することができないため、ガードしています。メタファイルに対応できたとしても、開発中のデータがIPFSに漏れることは避けるべきなので開発環境での動作確認は難しいと思われます。

if (!environment.production) {
return;
}

Fetch meta data

メタデータをfetchしています。メタデータのファイル名自体は決め打ちしておく必要があります。

// Fetch meta data.
const res = await fetch(`${this.baseUrl}${this.selfHostMetaPath}`);
if (!res.ok) {
throw new Error('No meta file for self host.');
}
const meta: SelfHostMeta = await res.json();

fetchMyself

次にfetchMyselfを呼び出して、メタデータを元に自分の全てのファイルをBlobで取得します。ブラウザがやっていることと同じことを重複してやっているのですが、/asset以下のファイルなど通常ブラウザが遅延して読み込むデータも全てfetchします。
ファイル名のデータも実はIPFSに追加する際にも必要になるため紐づけておきます。

private async fetchMyself(
meta: SelfHostMeta
): Promise<{ name: string; content: Blob }[]> {
const promises = meta.filenames.map((filename) =>
fetch(`${this.baseUrl}/${filename}`).then((res) => ({
name: filename,
content: res,
}))
);

const responses = await Promise.all(promises);
const src = responses.map((v) =>
v.content.blob().then((b) => ({ name: v.name, content: b }))
);
return Promise.all(src);
}

Add all files into IPFS node

先程取得したファイルをipfs-core-typesが提供してくれる型ImportCandidateStreamにマップしてaddAllメソッドでノードにファイルをまとめて追加できます。

// Prepare file contents.
const fileToAdd: ImportCandidateStream = files.map((f) => ({
path: `/${DIRECTORY_NAME}/${f.name}`,
content: f.content,
}));

// Add all files into IPFS node.
const added = this.ipfsNode.addAll(fileToAdd, { cidVersion: 1 });

実はIPFSへの追加データにはフォルダとファイルの2種類の概念があり、path: /tmp/hoge.jpgのようにパスを指定してファイルを追加すると/tmp/tmp/hoge.jpgの2つのデータが追加されます。フォルダという概念がないと静的サイトはパスを解決できないのでこうなっているようです。
今回は全てのファイルを同一階層に置きたいので上位フォルダ名にプロジェクト名をつけ、まとめておきます。最終的にフォルダのCIDがサイト全体のCIDになります。

this.ipfsNode.addAll(fileToAdd, { cidVersion: 1 });

addAllする際にcidVersion: 1のオプションを指定しています。
少し脇道にそれますが、CIDにはV0とV1があり、Qm****のようにQから始まる大文字小文字混合がV0、baf****のようにbから始まる小文字のみか大文字のみのCIDがV1です。V0、V1それぞれbase58btcbase32でエンコードされています。なぜ文字数的にはながいV1が必要かというと、ゲートウェイでサブドメインでのCIDの解決に対応するためです。

サブドメインによるCIDの解決
https://baf******ipfs.dweb.link
pathによるCIDの解決
https://ipfs.io/ipfs/Qm******

Webアプリケーションのルーティングについて考えてみると、pathによるCIDの解決ではルートパスは/ではなく/Qm****/になってしまいます。サブドメインで解決できれば考えることが少なくてすみます。今後はV1をデフォルトにしていく流れのようなので、V0を前提として開発しないほうが良いでしょう。

https://docs.ipfs.io/concepts/content-addressing/#identifier-formats


閑話休題。
最後に、追加した結果が非同期で返ってくるのでフォルダのCIDを取得して返します。

// Get directory CID.
for await (const v of added) {
if (v.path === DIRECTORY_NAME) {
this.directoryCID = v.cid;
}
}

return this.directoryCID;

generate-meta-file.sh

アプリケーションをビルドすると/dist以下にファイルが書き出されます。先程示したようにjsonの形で書き出されたファイルのリストを取得したいので、/dist以下を読み取ってファイルに出力させる必要があります。
やり方はいろいろあると思うのですが、迷ったらとりあえずシェルスクリプトがいいよね、ということでスクリプトを書いています。(webpackのプラグインを作成するとかするとビルド時に作成できるかもしれませんが手間がかかりそう)

scripts/generate-meta-file.sh
#!/bin/bash

# Default
VALUE_PATH="./dist/"
VALUE_FILENAME="appfilelist.json"

# Get arguments
while getopts p:n: OPT
do
  case $OPT in
    "p" ) FLG_PATH="TRUE" ; VALUE_PATH="$OPTARG";;
    "n" ) FLG_FILENAME="TRUE" ; VALUE_FILENAME="$OPTARG" ;;
  esac
done

# Validations
# Path validation.
if [ ${VALUE_PATH: -1} != "/" ]; then
  echo "Invalid path. It should be end with '/'. path: $VALUE_PATH"
  exit 1
fi

# File name validation.
if [[ "$VALUE_FILENAME" =~ .{5}$ ]] ; then
  if [ "${BASH_REMATCH[0]}" != ".json" ] ; then
      echo "Invalid file name. It should be end with '.json'. file name: $VALUE_FILENAME"
      exit 1
  fi
fi

# Prepare
pushd $VALUE_PATH
# Add export file
touch $VALUE_FILENAME
# Get file list from builded directory.
list=`find -type f`
pushd

# Remove './' prefix.
i=0
for eachValue in ${list[@]}; do
  list[$i]=${eachValue#\.\/} 
  let i++
done

# Format to json
i=0
for eachValue in ${list[@]}; do
  list[$i]="\"${eachValue}\""
  let i++
done
result="{\"filenames\":[$(IFS=","; echo "${list[*]}")]}"

# Exporting a file
echo "Exporting to $VALUE_PATH$VALUE_FILENAME"
echo $result
echo $result > $VALUE_PATH$VALUE_FILENAME

取り立てて詳しく説明すべきこともないですね。find -type fで取得したファイル名のリストをjsonに整形しているだけです。一応引数-p-nでビルド出力フォルダのパスとメタデータのファイル名を渡せるようにしてある程度汎用的に使えるようになっています。

呼び出し側の実装

サービスとメタデータの作成ができるようになったので、画面を修正します。

src/app/app.component.ts
import { Component, OnInit } from '@angular/core';
import * as ipfs from 'ipfs-core';
import { SelfHostingService } from './services/self-hosting.service';

const gatewayDomains: string[] = [
  'dweb.link',
  'cf-ipfs.com',
  'infura-ipfs.io'
]

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit {
  title = 'self-hosting-engine';
  id = ''
  agentVersion = ''
  cid: ipfs.CID | undefined;
  links: string[] = [];

  async ngOnInit(): Promise<void> {
    const node = await ipfs.create();

    const { id, agentVersion } = await node.id();
    this.id = id;
    this.agentVersion = agentVersion;

    const selfhost = new SelfHostingService(node);
    this.cid = (await selfhost.host()) || undefined;

    if (this.cid) {
      // Create links like: https://bafybeifoiziviatladxmb7hah756lddvyvlgv7t7mwwzr6ul7nkq7nsnjm.ipfs.dweb.link
      this.links = gatewayDomains.map(domain => `https://${this.cid?.toString()}.ipfs.${domain}`);
    }
  }
}

src/app/app.component.html
<h1>{{ title }}</h1>

<div>ID: {{ id }}</div>
<div>AgentVersion: {{ agentVersion }}</div>
<div>My CID: {{ cid?.toString() || '...' }}</div>

<ng-container *ngIf="0<links.length; else loading">
  <h3>This Web Site is Hosting by Myself Now!</h3>
  <ul>
    <li *ngFor="let link of links">
      <a target="_blank" href="{{ link }}">{{ link }} (opens in new tab)</a>
    </li>
  </ul>
  
</ng-container>
<ng-template #loading>
  loading...
</ng-template>

src/app/app.component.scss
h1, h2, h3 {
  color: green;
}
  • gatewayDomainsにはサブドメインに対応したゲートウェイのドメインを入れています。
  • CIDはV1になっているため変換の必要はありません。
  • cssを申し訳程度に追加したのはcssの動作確認のためです。

ビルド(とデプロイ)

プロダクションビルドとメタファイルの作成を行います。

// Angular12からは--prodオプションは不要になりました
$ yarn build
$ bash ./scripts/generate-meta-file.sh

これで/dist以下にファイルが書き出されます。

あとはこれらのファイルをS3などにホスティングして、HTTPSで表示できるようにします。静的ホスティングの方法などはドキュメントが溢れていると思いますので省略します。

まとめ

完成したコードはこちらに上げています。
https://github.com/ocknamo/self-hosting-engine

コンセプト通り自分自身をホストするアプリケーションができました。このアプリケーションは自分を保持するだけでなにもしていないのですが、ブログなどに仕込んだらもうちょっと面白いかもしれません。始めのホスト場所もIPFSノードにしたらより良さそうです。

ただしバンドルサイズが今の時点で2MB近くなっており、プロダクションに遊びで入れるにはちょっと重いかなと思います。

今回あまり触れませんでしたが、js-ipfsをプロダクション用途で使う場合シグナリングサーバを自分で立てる必要があります。そこらへんについてはこちらの記事を参考にしてください。
https://zenn.dev/ocknamo/articles/8ae5104fdd1926
https://blog.ipfs.io/2021-06-10-guide-to-ipfs-connectivity-in-browsers/

Discussion