Skip to content

Bun.serve() 는 온더플라이 압축, TLS 지원 및 Bun 네이티브 게시 - 구독 API 를 갖춘 서버 측 WebSocket 을 지원합니다.

참고

⚡️ 7 배 더 많은 처리량

Bun 의 WebSocket 은 빠릅니다. Linux x64 에서 간단한 채팅룸 의 경우 Bun 은 Node.js + "ws" 보다 초당 7 배 더 많은 요청을 처리할 수 있습니다.

초당 전송된 메시지 수런타임클라이언트
~700,000(Bun.serve) Bun v0.2.1 (x64)16
~100,000(ws) Node v18.10.0 (x64)16

내부적으로 Bun 의 WebSocket 구현은 uWebSockets 를 기반으로 구축되었습니다.


WebSocket 서버 시작

아래는 Bun.serve 로 구축된 간단한 WebSocket 서버로, 모든 들어오는 요청이 fetch 핸들러에서 WebSocket 연결로 업그레이드 됩니다. 소켓 핸들러는 websocket 매개변수에 선언됩니다.

ts
Bun.serve({
  fetch(req, server) {
    // 요청을 WebSocket 으로 업그레이드
    if (server.upgrade(req)) {
      return; // Response 를 반환하지 않음
    }
    return new Response("업그레이드 실패", { status: 500 });
  },
  websocket: {}, // 핸들러
});

다음 WebSocket 이벤트 핸들러가 지원됩니다.

ts
Bun.serve({
  fetch(req, server) {}, // 업그레이드 로직
  websocket: {
    message(ws, message) {}, // 메시지 수신
    open(ws) {}, // 소켓 열림
    close(ws, code, message) {}, // 소켓 닫힘
    drain(ws) {}, // 소켓이 더 많은 데이터 수신 준비됨
  },
});

속도를 위해 설계된 API

Bun 에서 핸들러는 소켓당 한 번이 아니라 서버당 한 번 선언됩니다.

ServerWebSocketopen, message, close, drainerror 에 대한 메서드가 있는 WebSocketHandler 객체를 Bun.serve() 메서드에 전달할 것을 기대합니다. 이는 클라이언트 측 WebSocket 클래스와 다릅니다. 이 클래스는 EventTarget(onmessage, onopen, onclose) 을 확장합니다.

클라이언트는 많은 소켓 연결이 열려 있지 않는 경향이 있으므로 이벤트 기반 API 가 의미가 있습니다.

하지만 서버는 많은 소켓 연결이 열려 있는 경향이 있습니다. 즉:

  • 각 연결에 대한 이벤트 리스너 추가/제거에 소요되는 시간이 누적됩니다
  • 각 연결에 대한 콜백 함수 참조를 저장하는 데 추가 메모리가 소요됩니다
  • 일반적으로 각 연결에 대해 새 함수를 생성하므로 메모리가 더 많이 소요됩니다

따라서 이벤트 기반 API 를 사용하는 대신 ServerWebSocketBun.serve() 에서 각 이벤트에 대한 메서드가 있는 단일 객체를 전달할 것을 기대하며 이는 각 연결에 대해 재사용됩니다.

이는 메모리 사용량이 줄어들고 이벤트 리스너를 추가/제거하는 데 소요되는 시간이 줄어듭니다.

각 핸들러의 첫 번째 인수는 이벤트를 처리하는 ServerWebSocket 인스턴스입니다. ServerWebSocket 클래스는 몇 가지 추가 기능이 있는 WebSocket 의 빠르고 Bun 네이티브 구현입니다.

ts
Bun.serve({
  fetch(req, server) {}, // 업그레이드 로직
  websocket: {
    message(ws, message) {
      ws.send(message); // 메시지 에코
    },
  },
});

메시지 전송

ServerWebSocket 인스턴스는 클라이언트에 메시지를 보내기 위한 .send() 메서드를 가지고 있습니다. 다양한 입력 타입을 지원합니다.

ts
Bun.serve({
  fetch(req, server) {}, // 업그레이드 로직
  websocket: {
    message(ws, message) {
      ws.send("Hello world"); // string
      ws.send(response.arrayBuffer()); // ArrayBuffer
      ws.send(new Uint8Array([1, 2, 3])); // TypedArray | DataView
    },
  },
});

헤더

업그레이드가 성공하면 Bun 은 명세 에 따라 101 Switching Protocols 응답을 보냅니다. 추가 headersserver.upgrade() 호출에서 이 Response 에 첨부할 수 있습니다.

ts
Bun.serve({
  fetch(req, server) {
    const sessionId = await generateSessionId();
    server.upgrade(req, {
      headers: { 
        "Set-Cookie": `SessionId=${sessionId}`, 
      }, 
    });
  },
  websocket: {}, // 핸들러
});

컨텍스트 데이터

컨텍스트 data.upgrade() 호출에서 새 WebSocket 에 첨부할 수 있습니다. 이 데이터는 WebSocket 핸들러 내부의 ws.data 속성에서 사용할 수 있습니다.

ws.data 를 강력하게 타입 지정하려면 websocket 핸들러 객체에 data 속성을 추가합니다. 이는 모든 수명 주기 후크에서 ws.data 를 타입 지정합니다.

ts
type WebSocketData = {
  createdAt: number;
  channelId: string;
  authToken: string;
};

Bun.serve({
  fetch(req, server) {
    const cookies = new Bun.CookieMap(req.headers.get("cookie")!);

    server.upgrade(req, {
      // 이 객체는 WebSocketData 를 준수해야 함
      data: {
        createdAt: Date.now(),
        channelId: new URL(req.url).searchParams.get("channelId"),
        authToken: cookies.get("X-Token"),
      },
    });

    return undefined;
  },
  websocket: {
    // TypeScript: ws.data 의 타입을 이렇게 지정
    data: {} as WebSocketData,
    // 메시지 수신 시 호출되는 핸들러
    async message(ws, message) {
      // ws.data 는 이제 제대로 WebSocketData 로 타입 지정됨
      const user = getUserFromToken(ws.data.authToken);

      await saveMessageToDatabase({
        channel: ws.data.channelId,
        message: String(message),
        userId: user.id,
      });
    },
  },
});

참고

참고: 이전에는 Bun.serve 에 타입 매개변수를 사용하여 ws.data 의 타입을 지정할 수 있었습니다. 예: Bun.serve<MyData>({...}). 이 패턴은 위에 표시된 data 속성을 선호하여 TypeScript 의 제한 사항 으로 인해 제거되었습니다.

브라우저에서 이 서버에 연결하려면 새 WebSocket 을 생성합니다.

js
const socket = new WebSocket("ws://localhost:3000/chat");

socket.addEventListener("message", event => {
  console.log(event.data);
});

참고

사용자 식별

현재 페이지에 설정된 쿠키는 WebSocket 업그레이드 요청과 함께 전송되며 fetch 핸들러의 req.headers 에서 사용할 수 있습니다. 이 쿠키를 구문 분석하여 연결하는 사용자의 신원을 확인하고 이에 따라 data 의 값을 설정합니다.

Pub/Sub

Bun 의 ServerWebSocket 구현은 토픽 기반 브로드캐스트를 위한 네이티브 게시 - 구독 API 를 구현합니다. 개별 소켓은 토픽 (문자열 식별자로 지정) 에 .subscribe() 하고 해당 토픽의 다른 모든 구독자에게 (자신을 제외하고) 메시지를 .publish() 할 수 있습니다. 이 토픽 기반 브로드캐스트 API 는 MQTTRedis Pub/Sub 과 유사합니다.

ts
const server = Bun.serve({
  fetch(req, server) {
    const url = new URL(req.url);
    if (url.pathname === "/chat") {
      console.log(`upgrade!`);
      const username = getUsernameFromReq(req);
      const success = server.upgrade(req, { data: { username } });
      return success ? undefined : new Response("WebSocket 업그레이드 오류", { status: 400 });
    }

    return new Response("Hello world");
  },
  websocket: {
    // TypeScript: ws.data 의 타입을 이렇게 지정
    data: {} as { username: string },
    open(ws) {
      const msg = `${ws.data.username}님이 채팅에 참여했습니다`;
      ws.subscribe("the-group-chat");
      server.publish("the-group-chat", msg);
    },
    message(ws, message) {
      // 이는 그룹 채팅입니다
      // 따라서 서버는 들어오는 메시지를 모든 사람에게 재전송합니다
      server.publish("the-group-chat", `${ws.data.username}: ${message}`);

      // 현재 구독 검사
      console.log(ws.subscriptions); // ["the-group-chat"]
    },
    close(ws) {
      const msg = `${ws.data.username}님이 채팅을 나갔습니다`;
      ws.unsubscribe("the-group-chat");
      server.publish("the-group-chat", msg);
    },
  },
});

console.log(`리스닝 중: ${server.hostname}:${server.port}`);

.publish(data) 를 호출하면 .publish() 를 호출한 소켓을 제외한 토픽의 모든 구독자에게 메시지가 전송됩니다. 토픽의 모든 구독자에게 메시지를 보내려면 Server 인스턴스의 .publish() 메서드를 사용합니다.

ts
const server = Bun.serve({
  websocket: {
    // ...
  },
});

// 일부 외부 이벤트 리스닝
server.publish("the-group-chat", "Hello world");

압축

메시지당 압축perMessageDeflate 매개변수로 활성화할 수 있습니다.

ts
Bun.serve({
  websocket: {
    perMessageDeflate: true, 
  },
});

압축은 .send() 의 두 번째 인수로 boolean 을 전달하여 개별 메시지에 대해 활성화할 수 있습니다.

ts
ws.send("Hello world", true);

압축 특성에 대한 세밀한 제어는 참조 를 참조하세요.

백프레셔

ServerWebSocket.send(message) 메서드는 작업 결과를 나타내는 number 를 반환합니다.

  • -1 — 메시지가 인큐되었지만 백프레셔가 있음
  • 0 — 연결 문제로 인해 메시지가 드롭됨
  • 1+ — 전송된 바이트 수

이를 통해 서버의 백프레셔를 더 잘 제어할 수 있습니다.

시간 제한 및 제한

기본적으로 Bun 은 120 초 동안 유휴 상태인 WebSocket 연결을 닫습니다. 이는 idleTimeout 매개변수로 구성할 수 있습니다.

ts
Bun.serve({
  fetch(req, server) {}, // 업그레이드 로직
  websocket: {
    idleTimeout: 60, // 60 초
  },
});

Bun 은 16 MB 보다 큰 메시지를 수신하면 WebSocket 연결을 닫습니다. 이는 maxPayloadLength 매개변수로 구성할 수 있습니다.

ts
Bun.serve({
  fetch(req, server) {}, // 업그레이드 로직
  websocket: {
    maxPayloadLength: 1024 * 1024, // 1 MB
  },
});

Websocket 서버에 연결

Bun 은 WebSocket 클래스를 구현합니다. ws:// 또는 wss:// 서버에 연결하는 WebSocket 클라이언트를 생성하려면 브라우저에서와 마찬가지로 WebSocket 인스턴스를 생성합니다.

ts
const socket = new WebSocket("ws://localhost:3000");

// 서브프로토콜 협상 사용
const socket2 = new WebSocket("ws://localhost:3000", ["soap", "wamp"]);

브라우저에서 현재 페이지에 설정된 쿠키는 WebSocket 업그레이드 요청과 함께 전송됩니다. 이는 WebSocket API 의 표준 기능입니다.

편의상 Bun 은 생성자에서 직접 사용자 정의 헤더를 설정할 수 있습니다. 이는 WebSocket 표준의 Bun 특정 확장입니다. 이것은 브라우저에서 작동하지 않습니다.

ts
const socket = new WebSocket("ws://localhost:3000", {
  headers: {
    /* 사용자 정의 헤더 */
  }, 
});

소켓에 이벤트 리스너를 추가하려면:

ts
// 메시지 수신
socket.addEventListener("message", event => {});

// 소켓 열림
socket.addEventListener("open", event => {});

// 소켓 닫힘
socket.addEventListener("close", event => {});

// 오류 핸들러
socket.addEventListener("error", event => {});

참조

ts
namespace Bun {
  export function serve(params: {
    fetch: (req: Request, server: Server) => Response | Promise<Response>;
    websocket?: {
      message: (ws: ServerWebSocket, message: string | ArrayBuffer | Uint8Array) => void;
      open?: (ws: ServerWebSocket) => void;
      close?: (ws: ServerWebSocket, code: number, reason: string) => void;
      error?: (ws: ServerWebSocket, error: Error) => void;
      drain?: (ws: ServerWebSocket) => void;

      maxPayloadLength?: number; // 기본값: 16 * 1024 * 1024 = 16 MB
      idleTimeout?: number; // 기본값: 120 (초)
      backpressureLimit?: number; // 기본값: 1024 * 1024 = 1 MB
      closeOnBackpressureLimit?: boolean; // 기본값: false
      sendPings?: boolean; // 기본값: true
      publishToSelf?: boolean; // 기본값: false

      perMessageDeflate?:
        | boolean
        | {
            compress?: boolean | Compressor;
            decompress?: boolean | Compressor;
          };
    };
  }): Server;
}

type Compressor =
  | `"disable"`
  | `"shared"`
  | `"dedicated"`
  | `"3KB"`
  | `"4KB"`
  | `"8KB"`
  | `"16KB"`
  | `"32KB"`
  | `"64KB"`
  | `"128KB"`
  | `"256KB"`;

interface Server {
  pendingWebSockets: number;
  publish(topic: string, data: string | ArrayBufferView | ArrayBuffer, compress?: boolean): number;
  upgrade(
    req: Request,
    options?: {
      headers?: HeadersInit;
      data?: any;
    },
  ): boolean;
}

interface ServerWebSocket {
  readonly data: any;
  readonly readyState: number;
  readonly remoteAddress: string;
  readonly subscriptions: string[];
  send(message: string | ArrayBuffer | Uint8Array, compress?: boolean): number;
  close(code?: number, reason?: string): void;
  subscribe(topic: string): void;
  unsubscribe(topic: string): void;
  publish(topic: string, message: string | ArrayBuffer | Uint8Array): void;
  isSubscribed(topic: string): boolean;
  cork(cb: (ws: ServerWebSocket) => void): void;
}

Bun by www.bunjs.com.cn 편집