今回たまたまクライアント側でElmを使ったけど、これはElmに限ったことではない。

結論

Client側での留意点

  • urllocalhost:[port]ではなくhttp://localhost:[port]と指定しなければならない。つまり、URLにはちゃんとスキーム名を指定する。

Server側での留意点

  • Access-Control-Allow-Originに関連するヘッダーをちゃんと設定する。

成功コード

プログラムの内容

サーバーは{ "msg" : "Hello, World!" }という内容のJSONを送ってくるので、クライアントはその値を受け取って"Success: Hello, World!“を出力する。それだけ。

Client: Elm

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
module Main exposing (..)
import Browser exposing (..)
import Json.Decode exposing (..)
import Http exposing (..)
import Html exposing (..)
import Html.Attributes exposing (..)

main = Browser.element
  { init = init
  , update = update
  , view = view
  , subscriptions = subscriptions
  }

type Model
  = Loading
  | Failed
  | Success String

init : () -> (Model, Cmd Msg)
init _ =
  ( Loading, getServer )

type Msg
  = GotData (Result Http.Error String)

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
  case msg of
    GotData result ->
      case result of
        Ok str ->
          (Success str, Cmd.none)
        Err _ ->
          (Failed, Cmd.none)

getServer : Cmd Msg
getServer =
  Http.get
    { url = "http://localhost:3000"
    , expect = Http.expectJson GotData dataDecoder
    }

dataDecoder : Decoder String
dataDecoder =
  field "msg" string

view : Model -> Html Msg
view model =
  case model of
    Failed ->
      p []
      [ text "Failed!" ]

    Loading ->
      p []
      [ text "Loading..." ]

    Success str ->
      p []
      [ text ("Success : " ++ str) ]

subscriptions : Model -> Sub Msg
subscriptions _ = Sub.none

Server: JavaScript (Node.js)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
const http = require('http');
const server = http.createServer();

server.on('request', (req, res) => {
  res.writeHead(200, {
    'Access-Control-Allow-Origin': '*',
    'Content-Type': 'application/json'
  });
  const body = {
    msg: 'Hello, World!'
  };
  res.write(JSON.stringify(body))
  res.end();
});

server.listen(3000);

失敗と解決までの流れ

Http.getの引数

初めはサーバー側で次のようにしていた。

1
2
3
4
5
6
7
8
server.on('request', (req, res) => {
  res.writeHead(200, { 'Content-Type': 'application/json' });
  const body = {
    msg: 'Hello, World!'
  };
  res.write(JSON.stringify(body))
  res.end();
});

Elm側でのGetメソッドは次のように呼び出していた。

1
2
3
4
5
6
getServer : Cmd Msg
getServer =
  Http.get
    { url = "localhost:3000"
    , expect = Http.expectJson GotData dataDecoder
    }

すると、ブラウザ(Safari)のConsole上で次のようなエラーが出ていた。

1
2
Cross origin requests are only supported for HTTP.
XMLHttpRequest cannot load localhost:3000 due to access control checks.

この時、「サーバー側はHTTPモジュールを使って通信してるのになんでonly supperted for HTTPなんだ…?」と思った。

調べても情報が見つからないので、自分でちゃんと考えてみる。「HTTPだけに対応しています」というエラーが出ているということは、サーバがHTTP通信として認識されていないということ。ではHTTP通信として認識されるための条件とは何か。

とりあえずパッと思いついたのは「ポート番号」。HTTP通信は基本的に80番ポートで通信しているはずで、その時はポートを指定する必要が無い。

そこで、クライアント側では以下のように変えた。サーバ側ではポートを80番でlistenすることにした。

1
2
3
4
  Http.get
    { url = "localhost"
    , expect = Http.expectJson GotData dataDecoder
    }

今度は次のようなエラーが出た。

1
Failed to load resource: the server responded with a status of 404 (Not Found)   http://localhost:8000/localhost

意味不明なURLhttp://localhost:8000/localhostにアクセスしようとしている…。elm reactorで起動しているWebサーバのURLhttp://localhost:8000とNode.js側のサーバのURLlocalhostが重なってなんか変なことになっている。

ここで、ふと「エラーメッセージだとlocalhostの前にhttp://が付いているな、これを試しにつけてみよう」と閃く。

1
2
3
4
  Http.get
    { url = "http://localhost"
    , expect = Http.expectJson GotData dataDecoder
    }

すると、次のようなエラーに変わった。

1
2
3
Origin http://localhost:8000 is not allowed by Access-Control-Allow-Origin.
XMLHttpRequest cannot load http://localhost/ due to access control checks.
Failed to load resource: Origin http://localhost:8000 is not allowed by Access-Control-Allow-Origin.

このエラーは以前別の機会で調べたことがあった。サーバ側で次のようにヘッダを付加してあげると解決。

1
2
3
4
res.writeHead(200, {
  'Access-Control-Allow-Origin': '*',
  'Content-Type': 'application/json'
});

これでうまく動くようになった。ここで、「実はクライアント側の問題はhttp://を付けていなかったことだけが原因?」と思い、次のように書き換えた。

1
2
3
4
  Http.get
    { url = "http://localhost:3000"
    , expect = Http.expectJson GotData dataDecoder
    }

サーバー側もポート3000でlistenしたところ、正常に動いた。

補足検証

クライアント側をElmではなくJSでやってみる。

やっぱり以下のコードだとエラーが出た。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
fetch('localhost:3000')
  .then(res => {
    return res.json();
  })
  .then(data => {
    return data.msg
  })
  .then(msg => {
    console.log(msg)
  })

これなら問題ない。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
fetch('http://localhost:3000')
  .then(res => {
    return res.json();
  })
  .then(data => {
    return data.msg
  })
  .then(msg => {
    console.log(msg)
  })

// ちなみにasync/awaitで書くとこうなる
(async () => {
  res = await fetch('http://localhost:3000');
  data = await res.json();
  console.log(await data.msg)
})()

結果の考察

localhostだとftp://localhostともhttp://localhostとも取れてしまうから、確かに必要だ、と後で思った。

いつもブラウザのURL欄にlocalhostと入力してアクセスできていたため、同じようにhttp://を省いて通信できるものだと無意識に思っていた。しかしこれが通用するのはブラウザだけで、恐らく「スキーマが指定されていなかったらhttp://をつけたものとみなす」という仕様なのだろう。