websocket-sync-transport.ts

2.8 KB · raw

1import { noiseNKInitiator } from './noise-js';2import { bytesToBase64, base64ToBytes } from './sync-wire';34export interface SyncTransport {5  send(data: Uint8Array): Promise<void>;6  receive(): Promise<Uint8Array>;7  close(): Promise<void>;8}910// Binary Noise NK frames are base64-encoded and sent as text WS frames so11// they work over the phone's text-only PeachWebSocketServer. Each frame is12// a plain base64 string (not JSON-wrapped) so the server's else-branch13// forwards it via peach:sync-frame without modification.14export async function createWebSocketSyncTransport(15  url: string,16  remotePublicKeyHex: string,17): Promise<SyncTransport> {18  const remotePublicKey = hexToBytes(remotePublicKeyHex);1920  return new Promise((resolve, reject) => {21    const ws = new WebSocket(url);2223    const messageQueue: string[] = [];24    const waiters: ((frame: string) => void)[] = [];2526    const enqueue = (frame: string) => {27      if (waiters.length > 0) {28        waiters.shift()!(frame);29      } else {30        messageQueue.push(frame);31      }32    };3334    ws.onmessage = (event) => enqueue(event.data as string);35    ws.onerror = () => reject(new Error('WebSocket connection failed'));36    ws.onclose = () => { waiters.forEach(w => w('')); waiters.length = 0; };3738    ws.onopen = async () => {39      try {40        const rawSend = (msg: Uint8Array) => {41          ws.send(bytesToBase64(msg));42          return Promise.resolve();43        };44        const rawReceive = (): Promise<Uint8Array> => new Promise((res) => {45          const handle = (frame: string) => {46            if (frame === '') { res(new Uint8Array(0)); return; }47            res(base64ToBytes(frame));48          };49          if (messageQueue.length > 0) handle(messageQueue.shift()!);50          else waiters.push(handle);51        });5253        // Noise NK handshake — desktop is always the initiator.54        const session = await noiseNKInitiator(remotePublicKey, rawSend, rawReceive);5556        resolve({57          send: async (plaintext) => {58            ws.send(bytesToBase64(session.encrypt(plaintext)));59          },60          receive: () => new Promise((res) => {61            const handle = (frame: string) => {62              if (frame === '') { res(new Uint8Array(0)); return; }63              res(session.decrypt(base64ToBytes(frame)));64            };65            if (messageQueue.length > 0) handle(messageQueue.shift()!);66            else waiters.push(handle);67          }),68          close: () => {69            ws.close();70            return Promise.resolve();71          },72        });73      } catch (err) {74        ws.close();75        reject(err);76      }77    };78  });79}8081function hexToBytes(hex: string): Uint8Array {82  const bytes = new Uint8Array(hex.length / 2);83  for (let i = 0; i < hex.length; i += 2) {84    bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);85  }86  return bytes;87}