Продвинутые Асинхронные Механизмы в JavaScript и node.js

На примере WebSocket

Обо мне

  • SDET с 2018
  • Senior SDET @b2broker
  • Certified node.js application developer (JSNAD 2023)
  • автор TG канала @haradkou_sdet
  • Консультирую кампании

Telegram: haradkou_sdet

Agenda

  • Websocket ↔️
  • Сообщения и логика 📩
  • Закрытие сокета🚪
  • Следим за памятью 👀

Контекст проекта

  • Trading terminal
  • Node.js as a client to .NET server
  • .NET signalR in server

Трейдинг терминал

Фото из официального сайта b2broker.com

Фото из официального сайта b2broker.com/bbp

Проблемы

  • нужно закрывать сокет самим
  • тесты параллельные, что может приводить к утечкам памяти
  • разнообразная логика на приходящие сообщения

WebSocket

WS API

import WebSocket from 'ws';

const ws = new WebSocket('ws://www.host.com/path');

ws.on('open', () => {
  console.log('socket open!');
})

ws.on('message', (msg) => {
  console.log('socket message:', msg);
})

ws.on('error', (err) => {
  console.log('socket error:', err);
})

ws.on('close', () => {
  console.log('socket close!');
})

Плюсы ws

  • большое комьюнити
  • понятная технология
  • прост в создании POC

Минусы ws

  • события добавляют когнитивную нагрузку - нужно помнить о том, что есть подписки
  • нужно уметь управлять жизненным циклом
  • событие на сообщение может иметь большую логику
    • нет встренного итератора по сообщениям, например для фильтрации по какому-либо признаку
  • логирование\трассировка
    • как понять что освободилась память после закрытия сокета?
    • как не потерять контекст в случае работы с внешними данными

Нужно тюнить!

Набросок

import ws from 'ws'

class MyWs {
  _ws: ws
  _messages: WsMessage[] = []
  _events: Function[] = []
  on(...args): void {}
  emit(): void {}
}

Ч1. итератор по сообщениям

проблема: события могут иметь большую логику обработки

Ч1. Ждем события

export function waitForEvent<T>(
  source: EventEmitter, 
  eventName: string
) {
  return new Promise<T>((resolve, reject) => {
    const eventHandler = (data: T) => {
      source.off(eventName, eventHandler)
      resolve(data)
    }
    source.once(eventName, eventHandler)
  })
}

Ч1. Итератор

import ws from 'ws'
import waitForEvent from './wait'

class MyWs {
  _ws: ws
  _events: Function[] = []
  on(event: string, ...args): void {}
  emit(event: string): void {}
  async *[Symbol.asyncIterator](){
    const message = await waitForEvent(this._ws, 'message')
    yield message
  }
  async *[Symbol.iterator](){
    const message = await waitForEvent(this._ws, 'message')
    yield message
  }
}

Ч1. Используем

import WebSocket from './my-ws'

async function main(){
 const ws = new WebSocket(/** */)
 for await (const message of ws) {
   console.log('Got message!', message)
   if(message === criteria) break
 }
}

main()

Ч1. Бонус

import ws from 'ws'
import waitForEvent from './wait'

class MyWs {
  async *[Symbol.asyncIterator](){ /** реализация */ }
  async *[Symbol.iterator](){ /** реализация */ }

  *messageFilter(cb: (data: Data) => boolean) {
    for await (const data of this) {
      const isMatched = cb(data);
      if(isMatched) yield data;
      else continue;
    }
  }
}

Ч1. Бонус

import WebSocket from "./my-ws";

async function main() {
  const ws = new WebSocket(/** */);
  for await (const message of ws.messageFilter(
    (data) => typeof data === "string",
  )) {
    console.log("Got string message!", message);
  }
}

main();

Ч1. Бонус Iterator Helpers

import WebSocket from "./my-ws";

async function main() {
  const ws = new WebSocket(/** */);
  for await (
    const message of ws
      .filter((data) => typeof data === "string")
      .take(10)
   ) {
    console.log("Got only 10 string messages!", message);
  }
}

main();

Ч2. Автоматическое закрытие сокета

  1. Timeout

    1. Promise.race + setTimeout

    2. Abort signal 

  2. Dispose & AsyncDispose

Ч2. Timeout

import ws from 'ws'

class MyWs {
  constructor(opts){
    this.signal = opts.signal ?? null
    
    if(this.signal) {
      this.signal.onabort = async (e) => {
        this.abort(e)
      }
    }
  }
  
  abort(reason) {
    this.close(reason)
  }

  close(reason) {/* реализация */}
}

Ч2. используем Timeout

import WebSocket from './my-ws'

async function main(){
 const signal = AbortSignal.timeout(3000)
 const ws = new WebSocket({ signal })
  // ws automatically closes after 3 sec
  ws.on('message', (msg) => {
    console.log(msg)
  })
}

main()

(Async) Dispose

  • Stage 2

  • Typescript 5.2+

  • Тоже что и defer в Go/Zig/Swift

  • По аналогии с iterator есть async версия

function loggy(id: string): AsyncDisposable {
    console.log(`Constructing ${id}`);
    return {
        async [Symbol.asyncDispose]() {
            console.log(`Disposing (async) ${id}`);
            await doWork();
        },
    }
}
async function func() {
    await using a = loggy("a");
    {
        await using b = loggy("b");
        await using c = loggy("c");
    }
}
func();
// Constructing a
// Constructing b
// Constructing c
// Disposing (async) c
// Disposing (async) b
// Disposing (async) a

Ч2. Async Dispose

import ws from 'ws'
import waitForEvent from './wait'

class MyWs implements AsyncDispose {
  _ws: ws
  on(...args): void {}
  emit(): void {}
  async close() {}
  
  async *[Symbol.asyncIterator](){ /** реализация */ }
  async *[Symbol.iterator](){ /** реализация */ }
  
  async [Symbol.asyncDispose](){
    await this._ws.close()
  }
}

Ч2. Используем

import WebSocket from './my-ws'

async function main(){
 await using ws = new WebSocket(/** */)
 for await (const message of ws) {
   console.log('Got message!', message)
   if(message === criteria) break
 }
  // ws automatically closes here
}

main()

Подводные камни

!Нельзя передать как аргумент в другие функции!

Ч3. Следим за памятью 👀

Дисклеймер! Эта фича у нас только планируется

Ч3. Для чего?

  • Для трассировки
  • Очистка ресурсов
  • Менеджмент кэша

Ч3. Минусы

  • недетериминированное поведение GC
  • не использовать для важных ресурсов!

Ч3. Can I use?

Ч3. Как это выглядит?

import WebSocket from 'ws';

// Создаем новый FinalizationRegistry и регистрируем callback функцию
const registry = new FinalizationRegistry((ws) => {
 // send analytics
});

class MyWs {
  constructor(url) {
    // Регистрируем объект в FinalizationRegistry
    registry.register(this, this.ws);
  }

  close() {
    if (this.ws) {
      this.ws.close();
      registry.unregister(this);
    }
    this.ws = null
  }
}

Ч4. Streams

(мем с погружением)

Ч4. Streams

Ч4. Streams

Ч4. Streams

  • Readable
  • Writable
  • Duplex
  • Transform

Ч4. Streams

import {
  ReadableStream,
  TransformStream,
} from 'node:stream/web';

const stream = new ReadableStream({
  start(controller) {
    controller.enqueue('a');
  },
});

const transform = new TransformStream({
  transform(chunk, controller) {
    controller.enqueue(chunk.toUpperCase());
  },
});

const transformedStream = stream.pipeThrough(transform);

for await (const chunk of transformedStream) {
 console.log(chunk);
  // Prints: A 
}

Ч4. Streams

Не покрытые темы

  • Web Streams
  • Async Resource
  • Async LocalStorage

Список литературы

Ч5. Async Resource

Text

PiterJS 2025. Async mechanics

By vitalic gorodkov

PiterJS 2025. Async mechanics

  • 123