1
\$\begingroup\$

A runtime agnostic WebSocket server. Tested with node, deno, bun, tjs, and chrome, see https://gist.github.com/guest271314/d330c7cea513f12ef7bf523c56431453.

Do I want feedback about any or all facets of the code?

any aspect of the code posted is fair game for feedback and criticism.

// JavaScript runtime agnostic WebSocket server
//
// Fork of https://gist.github.com/d0ruk/3921918937e234988dfaccfdee781bd3
//
// The Definitive Guide to HTML5 WebSocket by Vanessa Wang, Frank Salim, and Peter Moskovits
// p. 51, Building a Simple WebSocket Server
//
// guest271314 2025
// Do What the Fuck You Want to Public License WTFPLv2 http://www.wtfpl.net/about/

class WebSocketConnection {
  readable;
  writable;
  writer;
  buffer = new ArrayBuffer(0, { maxByteLength: 1024 ** 2 });
  closed = !1;
  opcodes = { TEXT: 1, BINARY: 2, PING: 9, PONG: 10, CLOSE: 8 };
  constructor(readable, writable, abortController) {
    this.readable = readable;
    this.incomingStreamController = void 0;
    this.incomingStream = new ReadableStream({
      start:(_) => { return this.incomingStreamController = _; }
    }); 
    this.abortController = abortController;
    if (writable instanceof WritableStreamDefaultWriter) {
      this.writer = writable;
    } else if (writable instanceof WritableStream) {
      this.writable = writable;
      this.writer = this.writable.getWriter();
    }
  }
  async processWebSocketStream() {
    try {
      for await (const frame of this.readable) {
       // console.log(this.closed);
        if (!this.closed) {
          const { byteLength } = this.buffer;
          // console.log(byteLength, frame.length);
          this.buffer.resize(byteLength + frame.length);
          const view = new DataView(this.buffer);
          for (let i = 0, j = byteLength; i < frame.length; i++, j++) {
            view.setUint8(j, frame.at(i));
          }
          await this.processFrame();
        } else {
          break;
        }
      }
      console.log("WebSocket connection closed.");
    } catch (e) {
      console.log(e);
      console.trace();
    }
  }
  async processFrame() {
    let length, maskBytes;
    const buf = new Uint8Array(this.buffer), view = new DataView(buf.buffer);
    if (buf.length < 2) {
      return !1;
    }
    let idx = 2,
      b1 = view.getUint8(0),
      fin = b1 & 128,
      opcode = b1 & 15,
      b2 = view.getUint8(1),
      mask = b2 & 128;
    length = b2 & 127;
    if (length > 125) {
      if (buf.length < 8) {
        return !1;
      }
      if (length == 126) {
        length = view.getUint16(2, !1);
        idx += 2;
      } else if (length == 127) {
        if (view.getUint32(2, !1) != 0) {
          await this.close(1009, "");
        }
        length = view.getUint32(6, !1);
        idx += 8;
      }
    }
    if (buf.length < idx + 4 + length) {
      return !1;
    }
    maskBytes = buf.subarray(idx, idx + 4);
    idx += 4;
    let payload = buf.subarray(idx, idx + length);
    payload = this.unmask(maskBytes, payload);
    // await this.handleFrame(opcode, payload);
    this.incomingStreamController.enqueue({ opcode, payload });
    if (this.buffer.byteLength === 0 && this.closed) {
      try {
        return !0;
      } catch (e) {
        console.log(e);
      }
    }
    if (idx + length === 0) {
      console.log(`this.buffer.length: ${this.buffer.byteLength}.`);
      return !1;
    }
    // console.log(this.buffer.byteLength, idx, length);

    for (let i = 0, j = idx + length; j < this.buffer.byteLength; i++, j++) {
      view.setUint8(i, view.getUint8(j));
    }
    this.buffer.resize(this.buffer.byteLength - (idx + length));
    return !0;
  }
  async handleFrame(opcode, buffer) {
    // console.log({ opcode, length: buffer.length });
    const view = new DataView(buffer.bufhttps://gist.github.com/guest271314/d330c7cea513f12ef7bf523c56431453fer);
    let payload;
    switch (opcode) {
      case this.opcodes.TEXT:
        payload = buffer;
        await this.writeFrame(opcode, payload);
        break;
      case this.opcodes.BINARY:
        payload = buffer;
        await this.writeFrame(opcode, payload);
        break;
      case this.opcodes.PING:
        await this.writeFrame(this.opcodes.PONG, buffer);
        break;
      case this.opcodes.PONG:
        break;
      case this.opcodes.CLOSE:
        let code, reason;
        if (buffer.length >= 2) {
          code = view.getUint16(0, !1);
          reason = buffer.subarray(2);
        }
        await this.close(code, reason);
        console.log("Close opcode.", new TextDecoder().decode(reason));
        this.incomingStreamController.close();
        break;
      default:
        await this.close(1002, "unknown opcode");
    }
  }
  async writeFrame(opcode, payload) {
    await this.writer.ready;
    return this.writer.write(this.encodeMessage(opcode, payload))
      .catch(console.log);
  }
  async send(obj) {
    // console.log({ obj });
    let opcode, payload;
    if (obj instanceof Uint8Array) {
      opcode = this.opcodes.BINARY;
      payload = obj;
    } else if (typeof obj == "string") {
      opcode = this.opcodes.TEXT;
      payload = obj;
    } else {
      throw new Error("Cannot send object. Must be string or Uint8Array");
    }
    await this.writeFrame(opcode, payload);
  }
  async close(code, reason) {
    const opcode = this.opcodes.CLOSE;
    let buffer;
    if (code) {
      buffer = new Uint8Array(reason.length + 2);
      const view = new DataView(buffer.buffer);
      view.setUint16(0, code, !1);
      buffer.set(reason, 2);
    } else {
      buffer = new Uint8Array(0);
    }
    console.log({ opcode, reason, buffer }, new TextDecoder().decode(reason));
    await this.writeFrame(opcode, buffer);
    await this.writer.close();
    await this.writer.closed;
    this.buffer.resize(0);
    this.closed = !0;
  }
  unmask(maskBytes2, data) {
    let payload = new Uint8Array(data.length);
    for (let i = 0; i < data.length; i++) {
      payload[i] = maskBytes2[i % 4] ^ data[i];
    }
    return payload;
  }
  encodeMessage(opcode, payload) {
    let buf, b1 = 128 | opcode, b2 = 0, length = payload.length;
    if (length < 126) {
      buf = new Uint8Array(payload.length + 2 + 0);
      const view = new DataView(buf.buffer);
      b2 |= length;
      view.setUint8(0, b1);
      view.setUint8(1, b2);
      buf.set(payload, 2);
    } else if (length < 65536) {
      buf = new Uint8Array(payload.length + 2 + 2);
      const view = new DataView(buf.buffer);
      b2 |= 126;
      view.setUint8(0, b1);
      view.setUint8(1, b2);
      view.setUint16(2, length);
      buf.set(payload, 4);
    } else {
      buf = new Uint8Array(payload.length + 2 + 8);
      const view = new DataView(buf.buffer);
      b2 |= 127;
      view.setUint8(0, b1);
      view.setUint8(1, b2);
      view.setUint32(2, 0, !1);
      view.setUint32(6, length, !1);
      buf.set(payload, 10);
    }
    return buf;
  }
  static KEY_SUFFIX = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
  static async hashWebSocketKey(secKeyWebSocket, writable) {
    // Use Web Cryptography API crypto.subtle where defined
    console.log(secKeyWebSocket, globalThis?.crypto?.subtle);
    const encoder = new TextEncoder();
    if (globalThis?.crypto?.subtle) {
      const key = btoa(
        [
          ...new Uint8Array(
            await crypto.subtle.digest(
              "SHA-1",
              encoder.encode(
                `${secKeyWebSocket}${WebSocketConnection.KEY_SUFFIX}`,
              ),
            ),
          ),
        ].map((s) => String.fromCodePoint(s)).join(""),
      );
      const header = `HTTP/1.1 101 Web Socket Protocol Handshake\r
Upgrade: WebSocket\r
Connection: Upgrade\r
sec-websocket-accept: ` + key + `\r
\r
`;
      return writable instanceof WritableStream
        ? (new Response(header)).body.pipeTo(writable, { preventClose: !0 })
        : writable.write(encoder.encode(header));
    } else {
      // txiki.js does not support Web Cryptography API crypto.subtle
      // Use txiki.js specific tjs:hashing or
      // https://raw.githubusercontent.com/kawanet/sha1-uint8array/main/lib/sha1-uint8array.ts
      const { createHash } = await import("./sha1-uint8array.min.js");
      const encoder = new TextEncoder();
      const hash = createHash("sha1").update(
        `${secKeyWebSocket}${WebSocketConnection.KEY_SUFFIX}`,
      ).digest();
      const key = btoa(
        String.fromCodePoint(...hash),
      );
      const header = "HTTP/1.1 101 Web Socket Protocol Handshake\r\n" +
        "Upgrade: WebSocket\r\n" +
        "Connection: Upgrade\r\n" +
        "Sec-Websocket-Accept: " + key + "\r\n\r\n";
      const encoded = encoder.encode(header);
      return writable instanceof WritableStream
        ? new Response(encode).body.pipeTo(writable, { preventClose: !0 })
        : writable.write(encoded);
    }
  }
}

export { WebSocketConnection };

Usage.

Here in Chromium browser in an Isolated Web App using Direct Sockets TCPServerSocket, in pertinent part, the server implemented in Chromium browser

  client.pipeTo(
    new WritableStream({
        start: (controller) => {
          this.ws = void 0;
          const {
            readable: wsReadable,
            writable: wsWritable
          } = new TransformStream,
          wsWriter = wsWritable.getWriter();
          Object.assign(this, {
            wsReadable,
            wsWritable,
            wsWriter
          });
        },
        write: async (r, controller) => {
            const request = decoder.decode(r);
            // console.log(request);
            // Handle WebSocket
            if (!/(GET|POST|HEAD|OPTIONS|QUERY)/i.test(request)) {
              await this.wsWriter.ready;
              await this.wsWriter.write(r);
            }
            // Handle WebSocket request
            if (/^GET/.test(request) && /websocket/i.test(request)) {
              const {
                headers,
                method,
                uri,
                servertype
              } = getHeaders(
                request,
              );
              const key = headers.get("sec-websocket-key");
              await WebSocketConnection.hashWebSocketKey(
                key,
                writer,
              );
              this.ws = new WebSocketConnection(this.wsReadable, writer);
              this.ws.processWebSocketStream().catch((e) => {
                throw e;
              });
              console.log(this.ws);

              this.ws.incomingStream.pipeTo(
                new WritableStream({
                  write: async ({
                    opcode,
                    payload
                  }) => {
                    await this.ws.handleFrame(opcode, payload);
                    if (opcode === 8) {
                      this.ws.incomingStreamController.close();
                    }
                  }
                })
              ).catch(() => {
                console.log(`Incoming stream closed`)
              });
            }

The client using WebSocketStream, in Chromium browser

// Only aborts *before* the handshake
var abortable = new AbortController();
var { signal } = abortable;
var wss = new WebSocketStream("ws://127.0.0.1:44818", {
  signal,
});
console.log(wss);

var { readable, writable } = await wss.opened.catch(console.warn);

var connection = wss.closed.then(({ closeCode, reason }) => {
  return `WebSocketStream closed. closeCode: ${closeCode}, reason: ${reason}`;
}).catch((e) => {
  return e.message;
});

var writer = writable.getWriter();
var reader = readable.getReader();
var len = 0;
var encoder = new TextEncoder();
var decoder = new TextDecoder();
var data = new Uint8Array(1024 ** 2).fill(97);
var len = 0;
for (let i = 0; i < data.length; i += 65536) {
  try {
    await writer.ready;
    await writer.write(data.subarray(i, i + 65536));
    // console.log(writer.desiredSize);
    const { value: v, done } = await reader.read();
    if (typeof v === "string") {
      console.log(v);
    } else {
      const decoded = decoder.decode(v, {
        stream: true,
      });
      console.log(len += v.byteLength, v, [...decoded].every((s) => s === "a"));
    }
  } catch (e) {
    console.warn(e);
  }
}

console.assert(len === data.buffer.byteLength, [len, data.buffer.byteLength]);
console.log(len, data.buffer.byteLength);
await writer.ready;
await writer.write("Text").then(() => reader.read()).then(console.log).catch(
  console.warn,
);

try {
  writer.releaseLock();
  reader.releaseLock();
  wss.close({
    closeCode: 1000,
    reason: "Done streaming",
  });
  // await writer.close();
  // await writer.closed;
} catch {}

function handleClose(args) {
  return args;
}
await Promise.allSettled([
  reader.closed.then(
    handleClose.bind(null, `readable.locked ${readable.locked}`),
  ).catch(handleClose.bind(null, `readable.locked ${readable.locked}`)),
  writer.closed.then(
    handleClose.bind(null, `writable.locked ${writable.locked}`),
  ).catch(handleClose.bind(null, `writable.locked ${writable.locked}`)),
  connection,
]).then((result) => console.log(result));

```
\$\endgroup\$

1 Answer 1

3
\$\begingroup\$

Some of the code is not very readable for no reason that I can discern, beyond maybe saving a few bytes, but if that is the goal there are other things that can be shortened/minimized

closed = false // instead of !1
closed = true // instead of !0
length = view.getUint16(2, false);
length = view.getUint32(6, false);
// etc

The function handleFrame has a switch case that could be reduced to remove duplicate code as TEXT and BINARY do the same thing.

case this.opcodes.TEXT:
case this.opcodes.BINARY:
  payload = buffer;
  await this.writeFrame(opcode, payload);
  break;

You surround a return !0 in a try...catch which seems unnecessary

if (this.buffer.byteLength === 0 && this.closed) {
    try {
        return !0;
    } catch (e) {
        console.log(e);
    }
}

could be:

if (this.buffer.byteLength === 0 && this.closed) {
    return true;
}

The async send function could set the payload once

async send(obj) {
    // console.log({ obj });
    let opcode, payload;
    if (obj instanceof Uint8Array) {
      opcode = this.opcodes.BINARY;
    } else if (typeof obj == "string") {
      opcode = this.opcodes.TEXT;
    } else {
      throw new Error("Cannot send object. Must be string or Uint8Array");
    }
    payload = obj;
    await this.writeFrame(opcode, payload);
}

The encodeMessage looks like it could be reduced as there is some repetitive code and some numbers that are the same

encodeMessage(opcode, payload) {
    let buf, b1 = 128 | opcode, b2 = 0, length = payload.length, buf_idx;
    const buf_extra = [2, 4, 10]
    if (length < 126) {
      buf_idx = 0
      b2 |= length;
    } else if (length < 65536) {
      buf_idx = 1
      b2 |= 126;
    } else {
      buf_idx = 2
      b2 |= 127;
    }
    buf = newUint8Array(payload.length + buf_extra[buf_idx])
    const view = new DataView(buf.buffer);
    view.setUint8(0, b1);
    view.setUint8(1, b2);
    if (length >= 126 && length < 65536) {
      view.setUint16(2, length);
    } else if (length >= 65536) {
      view.setUint32(2, 0, false);
      view.setUint32(6, length, false);
    }
    buf.set(payload, buf_extra[buf_idx]);
    return buf;
}

The function async hashWebSocketKey can be cleaned up as there is duplicate code. Also inside the else at the end it references encode (new Response(encode)) which seems to be un-initialized, I'm assuming typo

static async hashWebSocketKey(secKeyWebSocket, writable) {
    // Use Web Cryptography API crypto.subtle where defined
    console.log(secKeyWebSocket, globalThis?.crypto?.subtle);
    const encoder = new TextEncoder();
    let key
    if (globalThis?.crypto?.subtle) {
      key = btoa(
        [
          ...new Uint8Array(
            await crypto.subtle.digest(
              "SHA-1",
              encoder.encode(
                `${secKeyWebSocket}${WebSocketConnection.KEY_SUFFIX}`,
              ),
            ),
          ),
        ].map((s) => String.fromCodePoint(s)).join(""),
      );
    } else {
      // txiki.js does not support Web Cryptography API crypto.subtle
      // Use txiki.js specific tjs:hashing or
      // https://raw.githubusercontent.com/kawanet/sha1-uint8array/main/lib/sha1-uint8array.ts
      const { createHash } = await import("./sha1-uint8array.min.js");
      const hash = createHash("sha1").update(
        `${secKeyWebSocket}${WebSocketConnection.KEY_SUFFIX}`,
      ).digest();
      key = btoa(
        String.fromCodePoint(...hash),
      );
    }
    const header = "HTTP/1.1 101 Web Socket Protocol Handshake\r\n" +
        "Upgrade: WebSocket\r\n" +
        "Connection: Upgrade\r\n" +
        "Sec-Websocket-Accept: " + key + "\r\n\r\n";
    return writable instanceof WritableStream
        ? new Response(header).body.pipeTo(writable, { preventClose: true })
        : writable.write(encoder.encode(header));
}
\$\endgroup\$
4
  • \$\begingroup\$ Something in your suggestion changed the code to make it stop working, probably the encodeMessage() parts, since that's what you suggested changing the most of. \$\endgroup\$ Commented Aug 1 at 3:20
  • \$\begingroup\$ @guest271314 if you revert and add changes one by one you can find out and then I can try to review what isn't working \$\endgroup\$ Commented Aug 2 at 13:31
  • \$\begingroup\$ I'll try your code again. You can also test the code. \$\endgroup\$ Commented Aug 2 at 14:53
  • \$\begingroup\$ After re-testing your encodeMessage() does appears to work. Could be didn't work on initial testing because I renamed variables and used camel case instead of underscore. \$\endgroup\$ Commented Aug 2 at 15:10

You must log in to answer this question.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.