🌏

220行のJavaScriptでインターネットと検索エンジンとブラウザーを作った

2022/07/30に公開

デモ

https://htmlpreview.github.io/?https://github.com/harukaeru/MyInternet/blob/main/my_internet.html

↓「作ったブラウザ」から「作ったインターネット」上で「作った検索エンジン」を使って検索できている様子

作ったサイトは4つしかないです。どういったサイトにアクセスできるかは、下で定義している「コンピュータ部」などをご覧ください。

はじめに & 概要

この記事をご覧になっている方は、「JavaScriptでインターネットを作った」とはなんぞや?間違っているのではないか、と思っていると思います。

実は今回つくるインターネットは、かなり抽象と捨象をしているもので、インターネットのすべてを実装したわけではありません。ただし、クリティカルな部分はなかなかいい感じになっているのではないかなと思います。

実装したものは以下のとおりです。これらすべてが220行におさまっています(HTML部分は除いた行数ですが、HTMLも20行です)

my_internet.js

  • DNSルートサーバ
  • ISP(インターネットサービスプロバイダー)
    • IPアドレスを下記に記述のコンピュータに発行します(isp.issueIpAddress)
    • 今回は 0.0.0.10.0.0.255 までしか発行しません。
    • 今回は実装していませんが、ISPには、通常はICANNやJPNICが持っているIPアドレスの在庫を割り当てられています(ちなみにIPv4は枯渇済み)
    • 発行したIPアドレスと好きなドメインを作って、DNSルートサーバに登録することができます(ican.register。IPアドレスは、別に発行だけしておいてDNSに登録しなくてもいいです)
  • HTTP GET
    • URLを受け取るとwebサーバにアクセスしてドキュメントを取得します
    • queryParamsも1個だけ使えます(複数個は未実装)
  • コンピュータ
    • webサーバを返す関数です
      • webサーバにpathを指定するとドキュメントを返します
      • ドキュメントのフォーマットとしてHTMLを使えます。ただ、別にどうせテキストを返すだけなのでテキストならなんでもOKです。
  • クローラー&検索エンジンが実装されたコンピュータ
    • 上記で定義したコンピュータ上のWebサーバにアクセスし、Webスクレイピングを行ってさらにクローリングを行います
    • クローリング中、そのWebページのプレーンテキストをもとに検索インデックスを作ります
    • クローリングはBFS(幅優先探索)で行っています
    • pageRankは、雑に出現したらインクリメントしています
    • /search が呼ばれると内部でHTMLが生成されて、検索できます
    • ngramとかそういう高尚なことはしていません。ザ・雑です

my_internet.html & my_browser.js

  • アドレスバー

    • <input type="text"><button>を使った簡易的なものです
    • 「Go」ボタンを押すと、HTTP GETをして、div#document に中身を表示します
  • レンダラー

    • さすがにレンダリングはしんどいのでDOMParserを使っています
    • <a>タグが来たら、中身を改ざんして、クリックしたら実装したアドレスバーにhref属性をセットするようにしています
  • myWindow

    • window が本物のブラウザで定義されてしまっているので、myWindowというものを定義しています。
    • myWindow.hrefに値を代入すると、実装したアドレスバーの値が変更されます

実装

ネットワーク部

ICANNとISP、それからHTTP GETを作ります。これらはすべてのコンピュータ上でコール可能なものものとします。そのため、あとで生成するコンピュータ上から呼び出すことができます。ブラウザからも使えます。

  • icann.regsiterで、domain名(hogehoge.com)と、ipaddress(0.0.0.99)をDNSに登録する
  • isp.issueIpaddressで指定したコンピュータにIPアドレスを発行する
  • httpGet(website)で、指定したWebサイトのドキュメントが得られる

という3つの概念が重要です。httpGetでは、DNS名前解決を行って、コンピュータに到着後、クエリがあればクエリを渡してwebサーバのドキュメントを得ます。

ただ、ここではまだコンピュータの実装を紹介していないので、少しわかりにくいかもしれません。

// ------------------- ネットワークの構築 ---------------------
const icann = {
  dns: {},
  register: (domain, ipaddress) => {
    icann.dns[domain] = ipaddress
  }
}

const isp = {
  routingTable: {},
  reverseRoutingTable: {},
  ipcounter: 1,
  issueIpaddress: (computer) => {
    if (isp.ipcounter > 255) {
      console.error("We can't")
      return
    }

    const ipAddress = `0.0.0.${isp.ipcounter}`
    isp.routingTable[ipAddress] = computer
    isp.reverseRoutingTable[computer] = ipAddress
    isp.ipcounter++;
    return ipAddress
  }
}

const httpGet = (website) => {
  const urlAndQuery = website.split('?')
  const url = urlAndQuery[0]
  const query = urlAndQuery.length > 1 ? urlAndQuery[1] : ''

  const urlArray = url.replace('https://', '').replace('http://', '').split('/')
  const domain = urlArray[0]
  const path = '/' + (urlArray[1] ? urlArray[1] : '')
  const ipAddress = icann.dns[domain]
  const computer = isp.routingTable[ipAddress]
  if (!computer) {
    return null
  }
  const doc = computer().webServer[path]
  if (query) {
    const queryParams = query.split('=')
    return doc(queryParams[0], queryParams[1])
  }
  return doc
}

コンピュータ部

コンピュータを作っています。「マツリカ」とは、ぼくが勤めている会社のことです。

ここのコンピュータはただの関数です。返された値にwebServerがあって、webServerの中でpathを指定するとドキュメントが返されます。

computerが関数になっているのには理由があります(後述します)

実際には「サーバ」というより、ちょっと「サーバレス」っぽいかもしれません。こまけぇこたぁいいんだよ。

コンピュータを定義したあとは、その下の行でIPアドレスを発行して、ついでにドメインを登録しています。

// ------------------- コンピュータとWebサーバーの作成 ---------------------
const computer1 = () => {
  return {
    webServer: {
      '/': '<html><title>マツリカ top</title><div><a href="/about">マツリカについて</a></div><div><a href="https://youtube.com">動画サービスです</a></div></html>',
      '/about': '<html>すごいです</html>'
    }
  }
}
icann.register('mazrica.com', isp.issueIpaddress(computer1))

const computer2 = () => {
  return {
    webServer: {
      '/': '<html><title>かえるページ</title><div><a href="https://mazrica.com/">マツリカへ</a></div><div><a href="/movies">ぼくのつくった動画</a></div></html>',
      '/movies': '<html>ネコをたたえよ<video><source></source></video></html>'
    }
  }
}
icann.register('harukaeru.com', isp.issueIpaddress(computer2))

const computer3 = () => {
  return {
    webServer: {
      '/': '<html>すごい動画サービスです</html>',
    }
  }
}
icann.register('youtube.com', isp.issueIpaddress(computer3))

↓ この時点ですでにこのように動きます。

検索エンジン(コンピュータ部)

だいじな検索エンジン部分です。次の2つの関数が重要です。

  • crawl関数
    • httpGetでWebサイトを取得する
    • リンクがあったらそこのWebサイトをさらにクローリングするようにする(これはBFSを使っています)
    • Webサイトのプレーンテキストを searchIndex にぶちこむ
      • 本来はngramとかなんかいろいろ使ってもっとちゃんと検索できるようにするはずです
  • search関数
    • クローリングして手に入れた検索INDEXをもとにWebページのタイトルとそのリンクを返す
    • /search?q=fooのように呼ばれたときに、qの値を受け取って検索するので、そのときに呼ぶ

ついでに google.com というドメインに登録しています。

このようにsearch関数などを定義したかったので、computerを関数にする必要があったんですね(伏線回収)

const computer4 = () => {
  // -------------------- 検索エンジンをつくる ----------------------
  // クローラー。インターネットを探索して検索Indexを作成する
  const searchIndex = {}
  const searchedWebsiteUrls = new Set()
  const pageRank = {}
  const websiteTitles = {}
  const crawlingQueue = []
  const crawl = () => {
    while (crawlingQueue.length > 0) {
      const website = crawlingQueue.shift()
      if (searchedWebsiteUrls.has(website)) {
        pageRank[website]++;
        return
      }

      pageRank[website] = 1;

      const documentText = httpGet(website)
      const parser = new DOMParser()
      const htmlDoc = parser.parseFromString(documentText, 'text/html')

      const urlArray = website.replace('https://', '').replace('http://', '').split('/')
      const domain = urlArray[0]

      htmlDoc.querySelectorAll('a').forEach(aElement => {
        const newWebsiteUrl = (!(aElement.getAttribute('href').startsWith('/'))) ? aElement.href : 'https://' + domain + aElement.pathname
        crawlingQueue.push(newWebsiteUrl)
      })
      const title = htmlDoc.querySelector('title')

      const plainText = documentText.replace(/(<([^>]+)>)/g, "");

      websiteTitles[website] = title ? title.innerText : website
      if (searchIndex[plainText]) {
        searchIndex[plainText].add(website)
      } else {
        searchIndex[plainText] = new Set([website])
      }
    }
  }

  crawlingQueue.push('https://harukaeru.com')
  crawl()

  // 検索エンジン
  const search = (__, query) => {
    const websites = []
    Object.keys(searchIndex).filter(key => key.includes(query)).map(key => {
      const searchedWebsites = searchIndex[key]
      const array = Array.from(searchedWebsites)
      websites.push(...array)
    })
    websites.sort((a, b) => {
      return pageRank[a] > pageRank[b]
    })
    const result = websites.map(website => {
      return `<div>
        <a href="${website}">${websiteTitles[website]}</a>
       </div>
      `
    }).join('\n')

    return `<html>
      <title>グーグー</title>
      <input type="text" id="query"></input><button id="search" onclick="myWindow.href = 'https://google.com/search?q=' + document.getElementById('query').value">検索</button>
      <div>結果:</div>
      ${result}
      </script>
     </html>`
  }

  return {
    webServer: {
      '/': `<html>
        <title>グーグー</title>
        <input type="text" id="query"></input><button id="search" onclick="myWindow.href = 'https://google.com/search?q=' + document.getElementById('query').value">検索</button>
      </html>`,
      '/search': search
    }
  }
}
icann.register('google.com', isp.issueIpaddress(computer4))

↓このように検索できるようになりました。

ブラウザ部

ブラウザ部はいちばん簡単かもしれません。上記のものを表示するだけです。

説明は省略します。

const go = document.getElementById("go")
const targetUrl = document.getElementById("value_url")

const displayDocument = () => {
  const d = document.getElementById("document")
  const website = targetUrl.value
  const documentText = httpGet(website)
  if (!documentText) {
    d.innerText = '404'
    return
  }

  const parser = new DOMParser()
  const htmlDoc = parser.parseFromString(documentText, 'text/html')

  const urlArray = website.replace('https://', '').replace('http://', '').split('/')
  const domain = urlArray[0]

  htmlDoc.querySelectorAll('a').forEach(aElement => {
    const newWebsiteUrl = (!(aElement.getAttribute('href').startsWith('/'))) ? aElement.href : 'https://' + domain + aElement.pathname
    aElement.href = "javascript:void(0)"
    aElement.addEventListener('click', () => {
      targetUrl.value = newWebsiteUrl
      displayDocument()
    })
  })
  
  d.innerHTML = ''
  d.appendChild(htmlDoc.childNodes[0])
}

targetUrl.addEventListener('keypress', (event) => {
  if (event.key === "Enter") {
    displayDocument()
  }
})
go.addEventListener('click', () => {
  displayDocument()
})

const _myWindow = {}
const myWindow = new Proxy(_myWindow, {
  set: (target, key, value)  => {
    if (key = "href") {
      targetUrl.value = value
      displayDocument()
    }
  }
});

targetUrl.value = 'https://google.com'
displayDocument()

まとめ

実際のインターネットなどはもっともっと複雑なので、こうしたものを実装した先人の方はすごいですね!!

実装は簡略化しており、意図的に手を抜いた部分も多いですが、「ここはそもそも概念からして間違った理解をしているぞ!!!!?」というようなものがあった場合にはコメントいただけるとありがたいです。

(あとテストは適当なのでバグっているかもしれません。テストコードもないです)

↓コードはこちらにあります。

https://github.com/harukaeru/MyInternet

↓ いちばん上にもひっそりと貼ってありますが、デモしたい方はこちらからどうぞ。

https://htmlpreview.github.io/?https://github.com/harukaeru/MyInternet/blob/main/my_internet.html

Discussion