Skip to content

Add WebSocket server #705

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: master
Choose a base branch
from

Conversation

guest271314
Copy link
Contributor

Interoperability is all over the place.

For WebSocket clients

  • Chromium 138 Developer Build (Linux)

{ conn: { [Symbol(kHandle)]: {} } }
{ opcode: 8 }
Close opcode.
TypeError: Parameter 1 is required in 'respond'.
at $ (polyfills.js:28:14849)
at respond (polyfills.js:28:21853)
at pull (core.js{ conn: { [Symbol(kHandle)]: {} } }
{ opcode: 8 }
Close opcode.
WebSocket client connection closed
Error: EPIPE: broken pipe
:1:11961)

  • Firefox Nightly 140.0a1 (2025-05-07) (64-bit)

Works with 127.0.0.1:8080

Does not work with 0.0.0.0:8080

  • Bun 1.2.13

Works with builtin or Undici. Always causes broken pipe in tjs.

{ conn: { [Symbol(kHandle)]: {} } }
{ opcode: 8 }
Close opcode.
WebSocket client connection closed
Error: EPIPE: broken pipe

  • Deno 2.3.1+d372c0d (canary, release, x86_64-unknown-linux-gnu)

Does nothing. Using builtin or Undici. 0.0.0.0, 127.0.0.1, or localhost.

  • Node.js v24.0.0-nightly202505066102159fa1

Does nothing. Using builtin or Undici. 0.0.0.0, 127.0.0.1, or localhost.

For WebSocketStream clients

  • Chromium 138 Developer Build (Linux)

Works.

writer.close() does not cause broken pipe. A different error. Server does not exit.

TypeError: Parameter 1 is required in 'respond'.
at $ (polyfills.js:28:14849)
at respond (polyfills.js:28:21853)
at pull (core.js:1:11961)

  • Deno

Does nothing. Using builtin or Undici. 0.0.0.0, 127.0.0.1, or localhost.

  • Node.js

Does nothing. Using builtin or Undici. 0.0.0.0, 127.0.0.1, or localhost.

websocket-client.js. Echo 1 MB.

// import { WebSocket } from "undici";

var ws = new WebSocket("ws://127.0.0.1:8080");
ws.binaryType = "arraybuffer";
ws.addEventListener("open", (e) => {
  console.log(e);
  write();
});
ws.addEventListener("close", (e) => {
  console.log(e);
});
ws.addEventListener("message", (e) => {
  const v = e.data;
  if (typeof v === "string") {
    console.log(v);
  } else {
    const decoded = decoder.decode(v, {
      stream: true,
    });
    console.log(len += v.byteLength, [...decoded].every((s) => s === "a"));
  }
  if (len === data.buffer.byteLength) {
    console.log(ws.bufferedAmount);
    ws.close();
  }
});
ws.addEventListener("error", (e) => {
  console.log(e);
});

var len = 0;
var encoder = new TextEncoder();
var decoder = new TextDecoder();
var data = new Uint8Array(1024 ** 2).fill(97);
var len = 0;

function write() {
  for (let i = 0; i < data.length; i += 65536) {
    try {
      console.log(ws.bufferedAmount);
      ws.send(data.subarray(i, i + 65536));
    } catch (e) {
      console.warn(e);
    }
  }
}

websocket-stream-client.js. Echo 7 MB.

// Only aborts *before* the handshake
import { WebSocketStream } from "undici";
// Only aborts *before* the handshake
var abortable = new AbortController();
var {
  signal,
} = abortable;
var wss = new WebSocketStream("ws://0.0.0.0:8080", {
  signal
});
console.log(wss);

var {
  readable,
  writable,
} = await wss.opened.catch(console.warn);
wss.closed.then(() => console.log("WebSocketStream closed.")).catch((e) => {
  console.log(e);
});
console.log(readable);
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 * 7).fill(97);
var len = 0;
for (let i = 0; i < data.length; i += 65536) {
  try {
    await writer.ready;
    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.write("Text").then(() => reader.read()).then(console.log).catch(
  console.warn,
);
await writer.close();

Usage in tjs runtime tjs run tjs-websocket-server.js

import { WebSocketConnection } from "./websocket-server.js";

const decoder = new TextDecoder();

const listener = await tjs.listen("tcp", "0.0.0.0", "8080");
const { family, ip, port } = listener.localAddress;
console.log(
  `${navigator.userAgent} WebSocket server listening on family: ${family}, ip: ${ip}, port: ${port}`,
);

// TODO: Don't exit loop when broken pipe happens (Bun 1.2.13)
while (true) {
  try {
    const conn = await listener.accept();
    console.log({ conn });
    const writer = conn.writable.getWriter();
    const { readable: wsReadable, writable: wsWritable } = new TransformStream(),
      wsWriter = wsWritable.getWriter();
    let ws;
    for await (const value of conn.readable) {
      const request = decoder.decode(value);
      if (request.includes("Upgrade: websocket")) {
        const [key] = request.match(/(?<=Sec-WebSocket-Key: ).+/i);
        const handshake = await WebSocketConnection.hashWebSocketKey(
          key,
          writer,
        );
        ws = new WebSocketConnection(wsReadable, writer)
          .processWebSocketStream().catch((e) => {
            throw e;
          });
      } else {
        await wsWriter.ready;
        await wsWriter.write(new Uint8Array(value));
      }
    }

    console.log("WebSocket client connection closed");
    // await wsWriter.close();
  } catch (e) {
    // listener.close();
    console.log(e);
/*

{ conn: { [Symbol(kHandle)]: {} } }
{ opcode: 8 }
Close opcode.
WebSocket client connection closed
txiki.js/24.12.0 WebSocket server listening on family: 4, ip: 0.0.0.0, port: 8080
Error: EPIPE: broken pipe

Or

{ conn: { [Symbol(kHandle)]: {} } }
{ opcode: 8 }
Close opcode.
TypeError: Parameter 1 is required in 'respond'.
    at $ (polyfills.js:28:14849)
    at respond (polyfills.js:28:21853)
    at pull (core.js:1:11961)

Unpredictable which will happen, or not
*/
  } finally {
    continue;
  }
}

Interoperability is all over the place. 

For WebSocket clients

- Chromium 138 Developer Build (Linux)

{ conn: { [Symbol(kHandle)]: {} } }
{ opcode: 8 }
Close opcode.
TypeError: Parameter 1 is required in 'respond'.
    at $ (polyfills.js:28:14849)
    at respond (polyfills.js:28:21853)
    at pull (core.js{ conn: { [Symbol(kHandle)]: {} } }
{ opcode: 8 }
Close opcode.
WebSocket client connection closed
Error: EPIPE: broken pipe
:1:11961)


- Firefox Nightly 140.0a1 (2025-05-07) (64-bit)

Works with 127.0.0.1:8080

Does not work with 0.0.0.0:8080

- Bun 1.2.13

Works with builtin or Undici. Always causes broken pipe in tjs.

{ conn: { [Symbol(kHandle)]: {} } }
{ opcode: 8 }
Close opcode.
WebSocket client connection closed
Error: EPIPE: broken pipe

- Deno 2.3.1+d372c0d (canary, release, x86_64-unknown-linux-gnu)

Does nothing. Using builtin or Undici. 0.0.0.0, 127.0.0.1, or localhost.

- Node.js v24.0.0-nightly202505066102159fa1

Does nothing. Using builtin or Undici. 0.0.0.0, 127.0.0.1, or localhost.

For WebSocketStream clients

- Chromium 138 Developer Build (Linux)

Works.

writer.close() does not cause broken pipe. A different error. 
Server does not exit. 

TypeError: Parameter 1 is required in 'respond'.
    at $ (polyfills.js:28:14849)
    at respond (polyfills.js:28:21853)
    at pull (core.js:1:11961)

- Deno 

Does nothing. Using builtin or Undici. 0.0.0.0, 127.0.0.1, or localhost.

- Node.js 

Does nothing. Using builtin or Undici. 0.0.0.0, 127.0.0.1, or localhost.
@guest271314
Copy link
Contributor Author

More all over the place with regard to clients. When I adjust the code to use a single resizable ArrayBuffer to temporarily store incoming data to the server Chromium 138 works as expected. Firefox 140, and Bun, and Node.js, and Deno don't.

This

    const data = buf.subarray(idx + length);
    this.buffer.resize(data.length);
    for (let i = 0; i < this.buffer.byteLength; i++) {
      view.setUint8(i, data.at(i));
    }

causes this error

TypeError: ArrayBuffer is detached or resized
    at at (native)
    at processFrame (websocket-server.js:119:32)

but not when Chromium is the client.

It should be possible to use subarray() instead of slice() quickjs-ng/quickjs#1052
@guest271314
Copy link
Contributor Author

Updated usage, to handle mutiple requests with the same server instance

tjs tjs-websocket-server.js

import { WebSocketConnection } from "./websocket-server.js";

const decoder = new TextDecoder();

async function handleConnection(conn) {
  const writer = conn.writable.getWriter();
  const { readable: wsReadable, writable: wsWritable } = new TransformStream({}, {}, {
    highWaterMark: 1
  }),
    wsWriter = wsWritable.getWriter();
  let ws;
  for await (const value of conn.readable) {
    const request = decoder.decode(value);
    if (request.includes("Upgrade: websocket")) {
      const [key] = request.match(/(?<=Sec-WebSocket-Key: ).+/i);
      const handshake = await WebSocketConnection.hashWebSocketKey(
        key,
        writer,
      );
      ws = new WebSocketConnection(wsReadable, writer)
        .processWebSocketStream().catch((e) => {
          throw e;
        });
    } else {
      await wsWriter.ready;
      await wsWriter.write(new Uint8Array(value));
    }
  }

  console.log("WebSocket client connection closed");
  await wsWriter.close();
}
const listener = await tjs.listen("tcp", "0.0.0.0", "44818");
const { family, ip, port } = listener.localAddress;
console.log(
  `${navigator.userAgent} WebSocket server listening on family: ${family}, ip: ${ip}, port: ${port}`,
);

for await (const conn of listener) {
  try {
    console.log({ conn });
    handleConnection(conn).catch((e) => {
      console.log({ e });
    });
  } catch (e) {
    listener.close();
    console.log(e);
  }
}

Client support update.

Chromium client works. Firefox client works. Bun works, and always causes tjs to exit. Node.js (Undici) WebSocket and WebSocketStream clients, do not work, respectively. They each hang at or before open. Undici WritableStreamDefaultWriter.close() doesn't work the same as Chromium's implementation of WHATWG Streams TrasnformStream, Node.js expects ReadableStreamDefaultReader.close() to be called before writer.close(), and then fulfills the Promise with an error. Deno client doesn't work, also hangs at or around open.

The same code used in Chromium as a WebSocket server works for node, deno, bun, chrome, firefox, with the above-mentioned issues re each runtime.

@guest271314
Copy link
Contributor Author

This explains why node, deno, and bun were not working as intended

Deno

GET / HTTP/1.1
host: 127.0.0.1:44818
upgrade: websocket
connection: Upgrade
sec-websocket-key: XDSOkpJIiuT3YVzSkTuOjw==
user-agent: Deno/2.3.1+5044f2f
sec-websocket-version: 13

Node.js

GET / HTTP/1.1
host: 127.0.0.1:44818
connection: upgrade
upgrade: websocket
sec-websocket-key: Mq+14M4n9r+Lg9uPoBtPEA==
sec-websocket-version: 13
sec-websocket-extensions: permessage-deflate; client_max_window_bits
accept: */*
accept-language: *
sec-fetch-mode: websocket
user-agent: undici
pragma: no-cache
cache-control: no-cache
accept-encoding: gzip, deflate

Node.js

GET / HTTP/1.1
host: 127.0.0.1:44818
connection: upgrade
upgrade: websocket
sec-websocket-key: 48kkp4VpanJUq+FN2Y1yNw==
sec-websocket-version: 13
sec-websocket-extensions: permessage-deflate; client_max_window_bits
accept: */*
accept-language: *
sec-fetch-mode: websocket
user-agent: undici
pragma: no-cache
cache-control: no-cache
accept-encoding: gzip, deflate

Chromium

GET / HTTP/1.1
Host: 0.0.0.0:44818
Connection: Upgrade
Pragma: no-cache
Cache-Control: no-cache
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36
Upgrade: websocket
Origin: https://github.com
Sec-WebSocket-Version: 13
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9
Sec-WebSocket-Key: 686nTuO2/75HtVGiSHPbhA==
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits

Bun, using Node.js Undici WebSocketStream

GET / HTTP/1.1
host: 127.0.0.1:44818
connection: upgrade
upgrade: websocket
sec-websocket-key: Mq+14M4n9r+Lg9uPoBtPEA==
sec-websocket-version: 13
sec-websocket-extensions: permessage-deflate; client_max_window_bits
accept: */*
accept-language: *
sec-fetch-mode: websocket
user-agent: undici
pragma: no-cache
cache-control: no-cache
accept-encoding: gzip, deflate

Bun, using built-in WebSocket

GET / HTTP/1.1
Host: 127.0.0.1:44818
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: 3X8MOSPtQkqut+xMYqBC8Q==

Firefox

GET / HTTP/1.1
Host: 127.0.0.1:44818
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br, zstd
Sec-WebSocket-Version: 13
Origin: http://example.com
Sec-WebSocket-Extensions: permessage-deflate
Sec-WebSocket-Key: pbRTE+1Gg6vowdjEBMrpdQ==
Connection: keep-alive, Upgrade
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: websocket
Sec-Fetch-Site: cross-site
Pragma: no-cache
Cache-Control: no-cache
Upgrade: websocket

Notice the lowercase "upgrade". The fix when handling the request that does upgrade for the usage script is to substitute

    if (/upgrade: websocket/i.test(request)) {

for

    if (request.includes("Upgrade: websocket")) {

After checking for case-insensitive "<U|u>pgrade: websocket" node, deno, bun, chrome, firefox runtimes work as clients, both for WebSocket and WebSocketStream.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant