Post

[HTTP] 04. 추가 프로토콜 - 11장. SSE

[HTTP] 04. 추가 프로토콜 - 11장. SSE

04. 추가 프로토콜


11장. SSE

1
2
3
4
5
< Content-Type: text/event-stream
<
data: Hello

data: Hello again

11-1. 구조

  • 서버가 실시간으로 메시지를 보낸다.
  • 리소스를 효율적으로 사용할 수 있다.
  • 비유: 새 소식이 오면 알려주세요.

11-2. 서버 구현

  • 클라이언트 대기열 준비
  • 알림 구독 기능
  • 채팅 메시지 추가 기능
1
2
3
4
5
$ curl http://localhost:3000/subscribe -v

$ curl http://localhost:3000/update ^
--header "Content-Type: application:json" ^
--data "{\"text\": \"hello\"}" -v

11-3. 클라이언트 구현

  • EventSource
  • 수신한 메시지 출력

11-4. 재연결

  • EventSource 객체는 서버와 연결이 끊기면 자동으로 다시 연결
    • 시간 간격 커스텀 가능: retry로 설정
  • 이전에 받은 메시지가 있다면 last-event-id 헤더에 값을 실어서 보낸다.
1
2
3
4
5
6
7
waitingClient.write(
  [
    `retry: 10000\n`,
    `id: ${message.timestamp}\n`, // last event id
    `data: ${message}\n\n`, // 개행
  ].join('')
);

11-5. 중간 정리

  • 클라이언트와 서버 연결 유지 및 ‘실시간’ 메시지 전송 기법
  • EventSource
  • 특징: 실시간 알림을 위한 프로토콜
  • 주의사항: 단방향 메시지

참고


예제

파일구조

  • /ch11
    • public
      • favicon.ico
      • index.html
      • script.js
    • shared
      • message.js
      • serve-static.js
    • server.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const subscribe = () => {
  const eventSource = new EventSource('/subscribe'); // eventSource 객체
  eventSource.addEventListener('message', (event) => {
    render(JSON.parse(event.data));
  });
};

const render = (message) => {
  const messageElement = document.createElement('div');
  const { text } = message;
  const timestamp = new Date(message.timestamp).toLocaleTimeString();
  messageElement.textContent = `${text} (${timestamp})`;
  document.body.appendChild(messageElement);
};

const init = () => {
  subscribe();
};

document.addEventListener('DOMContentLoaded', init);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Message {
  constructor(text) {
    this.text = text;
    this.timestamp = Date.now();
  }

  toString() {
    return JSON.stringify({
      text: this.text,
      timestamp: this.timestamp,
    });
  }
}

module.exports = Message;
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
const fs = require('fs');
const path = require('path');

const serveStatic = (root) => {
  return (req, res) => {
    const filepath = path.join(root, req.url === '/' ? '/index.html' : req.url);

    fs.readFile(filepath, (err, data) => {
      if (err) {
        if (err.code === 'ENOENT') {
          res.statusCode = 404;
          res.write('Not Found\n');
          res.end();
          return;
        }

        res.statusCode = 500;
        res.write('Internal Server Error\n');
        res.end();
        return;
      }

      const ext = path.extname(filepath).toLowerCase();
      let contentType = 'text/html';
      switch (ext) {
        case '.html':
          contentType = 'text/html';
          break;
        case '.js':
          contentType = 'text/javascript';
          break;
        case '.css':
          contentType = 'text/css';
          break;
        case '.png':
          contentType = 'image/png';
          break;
        case '.json':
          contentType = 'application/json';
          break;
        case '.otf':
          contentType = 'font/otf';
          break;
        default:
          contentType = 'application/octet-stream';
      }
      res.setHeader('Content-Type', contentType);

      res.write(data);
      res.end();
    });
  };
};

module.exports = serveStatic;
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
65
66
67
68
const http = require('http');
const path = require('path');
const static = require('./shared/serve-static');
const Message = require('./shared/message');

let waitingClients = [];
let message = null;

const subscribe = (req, res) => {
  const lastEventId = req.headers['last-event-id'];
  console.log('lastEventId', lastEventId);

  res.setHeader('Content-Type', 'text/event-stream');
  res.write('\n');

  waitingClients.push(res);

  req.on('close', () => {
    waitingClients = waitingClients.filter((client) => client !== res);
  });
};

const update = (req, res) => {
  let body = '';

  req.on('data', (chunk) => {
    body = body + chunk.toString();
  });

  req.on('end', () => {
    const { text } = JSON.parse(body);

    if (!text) {
      res.statusCode = 400;
      res.setHeader('Content-Type', 'application/json');
      res.write(JSON.stringify({ error: 'text 필드를 채워주세요' }));
      res.end();
      return;
    }

    message = new Message(text);

    for (const waitingClient of waitingClients) {
      waitingClient.write(
        [
          `retry: 10000\n`,
          `id: ${message.timestamp}\n`, // last event id
          `data: ${message}\n\n`, // 개행
        ].join('')
      );
    }

    res.write(`${message}`);
    res.end();
  });
};

const handler = (req, res) => {
  const { pathname } = new URL(req.url, `http://${req.headers.host}`);

  if (pathname === '/subscribe') return subscribe(req, res);
  if (pathname === '/update') return update(req, res);

  static(path.join(__dirname, 'public'))(req, res);
};

const server = http.createServer(handler);
server.listen(3000, () => console.log('server is running ::3000'));
This post is licensed under CC BY 4.0 by the author.

Trending Tags