banner
innei

innei

写代码是因为爱,写到世界充满爱!
github
telegram
twitter

Good news: SharedWorker implements a singleton WebSocket across pages.

In a previous article, I detailed how the real-time headcount at the site is achieved. In the solution, I left a suspense about how to use SharedWorker to share a Socket instance between pages, achieving a cross-page singleton WebSocket.

Motivation#

The background for exploring this issue came from seeing a question about WebSocket on Zhihu, where one of the answers mentioned that WebSocket connections consume too many server resources. When multiple pages are opened repeatedly, each page establishes a WebSocket connection, inadvertently increasing server pressure. Although I had considered this issue before, I didn't delve into it because my personal site doesn't have a large number of users online simultaneously. We use Socket.IO to establish WebSocket connections, which is a wrapper around WebSocket, so Socket.IO incurs slightly more overhead than native WebSocket. The documentation mentions the memory overhead of Socket.IO connections on the server side: Memory Usage | Socket.IO.

Although this performance optimization is not critical and does not provide any improvement, since there is this issue, let's try to solve it this time.

Finding a Solution#

The problem we need to solve is clear: when a browser opens two or more pages of our site (multiple tabs), reuse the same Socket instance.

Through keyword searches, we learned about an API, SharedWorker. A SharedWorker is a worker that is shared between multiple browsing contexts (such as multiple windows, tabs, or iframes). SharedWorker has a global scope that can be used across multiple browsing contexts, allowing us to achieve our goal.

We just need to move the original Socket instance code into the SharedWorker and then communicate with the Socket instance in the Worker from the page. Since the previous SocketClient abstracted the Socket, we don't need to modify much code in this refactor; we only need to implement the communication layer.

Basic Usage of SharedWorker#

Let's take a look at how SharedWorker is used.

let i = 0

const ports = []

onconnect = (e) => {
  const port = e.ports[0];

  ports.push(port)

  port.addEventListener("message", (e) => {
    const workerResult = `Result: ${e.data[0] * e.data[1]}`;
    port.postMessage(workerResult);
  });
  port.start();
};
setInterval(() => {
  i++;
  ports.forEach(port => port.postMessage("Message from worker, time: " + i))
}, 1000);
const worker = new SharedWorker('/shared-worker.js')
worker.port.onmessage = (e) => {
  console.log('Message received from worker', e.data)
}
worker.port.start()

In the example above, we created a SharedWorker and communicated with the Worker from the page. In the Worker, we listened for the onconnect event, and when the Worker connects, we listened for the port's message event. Then, in the page, we listened for the port's message event, and messages from the worker are further processed in the page.

Like Worker, messages in SharedWorker are transmitted via MessageChannel. The worker.port is essentially a wrapper around MessageChannel.

Now let's run this basic example and see the effect.

https://codesandbox.io/p/sandbox/ecstatic-jerry-6xsgvs

When we open two tabs simultaneously, we can see that both tabs receive messages sent by the Worker.

image

Through chrome://inspect/#workers, we can see the Worker information. When multiple tabs are opened, the SharedWorker is shared between them; when all pages are closed, the Worker is destroyed.

Using SharedWorker in Next.js#

In the example above, we placed the worker implementation in the public directory, which is not suitable for project-based applications because, in such projects, we should not directly reference files in the public directory but should import them instead. Secondly, we may need to reference external libraries in the Worker implementation or use TypeScript to write the Worker. For these requirements, directly writing raw JS is not feasible.

To address this, we need to make some modifications to the Next.js webpack configuration to support Worker imports.

First, install worker-loader:

npm i -D worker-loader

Modify next.config.js:

/** @type {import('next').NextConfig} */

let nextConfig = {
  webpack: (config, { webpack }) => {
    config.module.rules.unshift({
      test: /\.worker\.ts$/,
      loader: 'worker-loader',
      options: {
        publicPath: '/_next/',
        worker: {
          type: 'SharedWorker',
          // https://v4.webpack.js.org/loaders/worker-loader/#worker
          options: {
            name: 'ws-worker',
          },
        },
      },
    })

    return config
  },
}

export default nextConfig

Here we used worker-loader and configured the file suffix .worker.ts for the SharedWorker implementation, allowing us to use SharedWorker in Next.js.

const worker = new SharedWorker("/worker.js"); // ![code --]
import worker from './worker.worker' // ![code ++]

Implementing SharedWorker Socket#

Now we will implement the Socket logic in the Worker.

Let's outline the process:

  1. First, when establishing the Worker (i.e., the first page will establish the Worker), pass the Socket connection configuration. (Because the Worker cannot access environment variables and main thread variables)
  2. Create a Socket instance, then listen to the Socket connection status and message events, and pass messages to the main thread (page) via MessageChannel.
  3. When a new page opens or the Socket connection is completed, pass the Socket-related information to the main thread. This data needs to be stored by the main process for consumption by other components.
  4. In the main process, implement communication with the Worker and operations on the Socket instance, such as the emit method.
Mermaid Loading...

The specific code implementation is as follows:

Note

In the example below, we used TypeScript to write the Worker, and we can directly use import to reference external libraries in the Worker. To better support the types of Worker Scope, we use /// <reference lib="webworker" /> at the top to add relevant type support.

import { io } from 'socket.io-client'
import type { Socket } from 'socket.io-client'

/// <reference lib="webworker" />

let ws: Socket | null = null

function setupIo(config: { url: string }) {
  if (ws) return
  // Using socket.io
  console.log('Connecting to io, url: ', config.url)

  ws = io(config.url, {
    timeout: 10000,
    reconnectionDelay: 3000,
    autoConnect: false,
    reconnectionAttempts: 3,
    transports: ['websocket'],
  })
  if (!ws) return

  ws.on('disconnect', () => {
    broadcast({
      type: 'disconnect',
    })
  })

  /**
   * @param {any} payload
   */
  ws.on('message', (payload) => {
    console.log('ws', payload)

    broadcast({
      type: 'message',
      payload,
    })
  })

  ws.on('connect', () => {
    console.log('Connected to ws.io server from SharedWorker')

    if (waitingEmitQueue.length > 0) {
      waitingEmitQueue.forEach((payload) => {
        if (!ws) return
        ws.emit('message', payload)
      })
      waitingEmitQueue.length = 0
    }
    broadcast({
      type: 'connect',
      // @ts-expect-error
      payload: ws.id,
    })
  })

  ws.open()
  broadcast({
    type: 'sid',
    payload: ws.id,
  })
}

const ports = [] as MessagePort[]

self.addEventListener('connect', (ev: any) => {
  const event = ev as MessageEvent

  const port = event.ports[0]

  ports.push(port)

  port.onmessage = (event) => {
    const { type, payload } = event.data
    console.log('get message from main', event.data)

    switch (type) {
      case 'config':
        setupIo(payload)
        break
      case 'emit':
        if (ws) {
          if (ws.connected) ws.emit('message', payload)
          else waitingEmitQueue.push(payload)
        }
        break
      case 'reconnect':
        if (ws) ws.open()
        break
      case 'init':
        port.postMessage({ type: 'ping' })

        if (ws) {
          if (ws.connected) port.postMessage({ type: 'connect' })
          port.postMessage({ type: 'sid', payload: ws.id })
        }
        break
      default:
        console.log('Unknown message type:', type)
    }
  }

  port.start()
})

function broadcast(payload: any) {
  console.log('[ws] broadcast', payload)
  ports.forEach((port) => {
    port.postMessage(payload)
  })
}

const waitingEmitQueue: any[] = []

The Worker is done, and now we need to write the code for communication between the main thread and the Worker.

Let's first look at the process:

Mermaid Loading...

The specific code implementation is as follows:

Important

In the example below, we asynchronously load to initialize the SharedWorker in the constructor to avoid errors when the Worker does not exist on the Server Side.

interface WorkerSocket {
  sid: string
}

class SocketWorker {
  private socket: WorkerSocket | null = null

  worker: SharedWorker | null = null

  constructor() {
    if (isServerSide) return
    // @ts-expect-error
    import('./io.worker').then(({ default: SharedWorker }) => {
      if (isServerSide) return
      const worker = new SharedWorker()

      this.prepare(worker)
      this.worker = worker
    })
  }

  async getSid() {
    return this.socket?.sid
  }

  private setSid(sid: string) {
    this.socket = {
      ...this.socket,
      sid,
    }
  }
  bindMessageHandler = (worker: SharedWorker) => {
    worker.port.onmessage = (event: MessageEvent) => {
      const { data } = event
      const { type, payload } = data

      switch (type) {
        case 'ping': {
          worker?.port.postMessage({
            type: 'pong',
          })
          console.log('[ws worker] pong')
          break
        }
        case 'connect': {
          window.dispatchEvent(new SocketConnectedEvent())
          setSocketIsConnect(true)

          const sid = payload
          this.setSid(sid)
          break
        }
        case 'disconnect': {
          window.dispatchEvent(new SocketDisconnectedEvent())
          setSocketIsConnect(false)
          break
        }
        case 'sid': {
          const sid = payload
          this.setSid(sid)
          break
        }
        case 'message': {
          const typedPayload = payload as string | Record<'type' | 'data', any>
          if (typeof typedPayload !== 'string') {
            return this.handleEvent(
              typedPayload.type,
              camelcaseKeys(typedPayload.data),
            )
          }
          const { data, type } = JSON.parse(typedPayload) as {
            data: any
            type: EventTypes
          }
          this.handleEvent(type, camelcaseKeys(data))
        }
      }
    }
  }

  prepare(worker: SharedWorker) {
    const gatewayUrlWithoutTrailingSlash = GATEWAY_URL.replace(/\/$/, '')
    this.bindMessageHandler(worker)
    worker.port.postMessage({
      type: 'config',

      payload: {
        url: `${gatewayUrlWithoutTrailingSlash}/web`,
      },
    })

    worker.port.start()

    worker.port.postMessage({
      type: 'init',
    })
  }
  handleEvent(type: EventTypes, data: any) {
    // Handle biz event
  }

  emit(event: SocketEmitEnum, payload: any) {
    this.worker?.port.postMessage({
      type: 'emit',
      payload: { type: event, payload },
    })
  }

  reconnect() {
    this.worker?.port.postMessage({
      type: 'reconnect',
    })
  }

  static shared = new SocketWorker()
}

export const socketWorker = SocketWorker.shared
export type TSocketClient = SocketWorker

So it's done, the SocketWorker is basically abstracted from the original SocketClient, implementing the same methods, so there is not much change in usage in the business, and the migration process is very smooth.

By the way, this refactor is located at Shiro/550abd. For reference.

The completed implementation is located at:

https://github.com/Innei/Shiro/blob/c399372f7cc1bff55f842ff68342ffb0071b5ae6/src/socket/io.worker.ts

This article was synchronized to xLog by Mix Space. The original link is https://innei.in/posts/tech/using-sharedworker-singleton-websocket-in-nextjs

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.