commit b42d807fdc985cb3182193acd8220e9190857ae2 from: Omar Polo date: Sat Sep 02 07:55:29 2023 UTC amused-web: add websocket support; send forms via js if available This allows amused-web to stay in sync using websockets (much like existing amused clients watch `amused monitor') and sends the forms in the background to avoid refreshing the page. Still missing is reopening the websocket, maybe blocking the UI in that case and showing the progress. commit - b9d67604deb91635f67545a801571b0298a44274 commit + b42d807fdc985cb3182193acd8220e9190857ae2 blob - 17eedaa131fe1124d6e51e5c42ce247881ebf580 blob + d40f9a527fa020bf0e70843b30c6fe2e38585697 --- web/Makefile +++ web/Makefile @@ -2,11 +2,13 @@ PROG = amused-web -SOURCES = web.c bufio.c http.c ../ev.c ../log.c ../playlist.c ../xmalloc.c +SOURCES = web.c bufio.c http.c ws.c \ + ../ev.c ../log.c ../playlist.c ../xmalloc.c OBJS = ${SOURCES:.c=.o} -DISTFILES = Makefile amused-web.1 bufio.c bufio.h http.c http.h web.c web.h +DISTFILES = Makefile amused-web.1 bufio.c bufio.h http.c http.h \ + web.c web.h ws.c ws.h all: ${PROG} blob - 6f9ed2e503f1d40a0a2ccc237fdaeb2faa9f056a blob + cdb50b8169ff7e99980528db5e4a24a1636eafd5 --- web/http.c +++ web/http.c @@ -39,6 +39,7 @@ #include "bufio.h" #include "http.h" #include "log.h" +#include "ws.h" #include "xmalloc.h" #ifndef nitems @@ -134,6 +135,38 @@ http_parse(struct client *clt) } } + if (!strncasecmp(line, "Connection:", 11)) { + line += 11; + line += strspn(line, " \t"); + if (!strcasecmp(line, "upgrade")) + req->flags |= R_CONNUPGR; + } + + if (!strncasecmp(line, "Upgrade:", 8)) { + line += 8; + line += strspn(line, " \t"); + if (!strcasecmp(line, "websocket")) + req->flags |= R_UPGRADEWS; + } + + if (!strncasecmp(line, "Sec-WebSocket-Version:", 22)) { + line += 22; + line += strspn(line, " \t"); + if (strcmp(line, "13") != 0) { + log_warnx("unsupported websocket version %s", + line); + errno = EINVAL; + return -1; + } + req->flags |= R_WSVERSION; + } + + if (!strncasecmp(line, "Sec-WebSocket-Key:", 18)) { + line += 18; + line += strspn(line, " \t"); + req->secret = xstrdup(line); + } + buf_drain(rbuf, endln - rbuf->buf + 2); } @@ -188,8 +221,17 @@ int http_reply(struct client *clt, int code, const char *reason, const char *ctype) { const char *version, *location = NULL; + char b32[32] = ""; log_debug("> %d %s", code, reason); + + if (code == 101) { + if (ws_accept_hdr(clt->req.secret, b32, sizeof(b32)) == -1) { + clt->err = 1; + return -1; + } + clt->chunked = 0; + } if (code >= 300 && code < 400) { location = ctype; @@ -214,6 +256,12 @@ http_reply(struct client *clt, int code, const char *r if (clt->chunked && bufio_compose_str(&clt->bio, "Transfer-Encoding: chunked\r\n") == -1) goto err; + if (code == 101) { + if (bufio_compose_fmt(&clt->bio, "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Accept: %s\r\n", b32) == -1) + goto err; + } if (bufio_compose(&clt->bio, "\r\n", 2) == -1) goto err; @@ -384,6 +432,7 @@ void http_free(struct client *clt) { free(clt->req.path); + free(clt->req.secret); free(clt->req.ctype); free(clt->req.body); bufio_free(&clt->bio); blob - 1f705f7049092a5e168e49847e307209b0bb8d01 blob + 2403eca6280267892002ab5278333b526468bfef --- web/http.h +++ web/http.h @@ -40,14 +40,21 @@ struct request { char *path; int method; int version; + char *secret; char *ctype; char *body; size_t clen; + +#define R_CONNUPGR 0x01 +#define R_UPGRADEWS 0x02 +#define R_WSVERSION 0x04 + int flags; }; struct client; typedef void (*route_fn)(struct client *); +TAILQ_HEAD(clthead, client); struct client { char buf[1024]; size_t len; @@ -55,9 +62,12 @@ struct client { struct request req; int err; int chunked; + int ws; /* if talking ws:// */ int reqdone; /* done parsing the request */ int done; /* done handling the client */ route_fn route; + + TAILQ_ENTRY(client) clients; }; int http_init(struct client *, int); blob - d0e29659553acccbbca336da4bb4e720ee085282 blob + e5c8abce4ceec5881bf44405d08a606365cd653d --- web/web.c +++ web/web.c @@ -41,6 +41,7 @@ #include "http.h" #include "log.h" #include "playlist.h" +#include "ws.h" #include "xmalloc.h" #ifndef nitems @@ -58,6 +59,7 @@ #define ICON_TOGGLE "⏯" #define ICON_PLAY "⏵" +static struct clthead clients; static struct imsgbuf ibuf; static struct playlist playlist_tmp; static struct player_status player_status; @@ -65,6 +67,8 @@ static uint64_t position, duration; static const char *prefix = ""; static size_t prefixlen; +static void client_ev(int, int, void *); + const char *head = "" "" "" @@ -156,13 +160,84 @@ const char *css = "*{box-sizing:border-box}" "}"; const char *js = + "var ws;" + "let pos=0, dur=0;" + "const playlist=document.querySelector('.playlist');" "function cur(e) {" " if (e) {e.preventDefault()}" " let cur = document.querySelector('#current');" " if (cur) {cur.scrollIntoView(); window.scrollBy(0, -100);}" + "};" + "function b(x){return x=='on'};" + "function c(p, c){" + " const l=document.createElement('li');" + " if(c){l.id='current'};" + " const b=document.createElement('button');" + " b.type='submit'; b.name='jump'; b.value=p;" + " b.innerText=p;" + " l.appendChild(b);" + " playlist.appendChild(l);" "}" + "function d(t){" + " const [, type, payload] = t.split(/^(.):(.*)$/);" + " if (type=='s'){" + " let s=payload.split(' ');" + " pos=s[0], dur=s[1];" + " } else if (type=='S') {" + " const btn=document.querySelector('#toggle');" + " if (payload=='playing') {" + " btn.innerHTML='"ICON_PAUSE"';" + " btn.value='pause';" + " } else {" + " btn.innerHTML='"ICON_PLAY"';" + " btn.value='play';" + " }" + " } else if (type=='r') {" + " const btn=document.querySelector('#rone');" + " btn.className=b(payload)?'mode-active':'';" + " } else if (type=='R') {" + " const btn=document.querySelector('#rall');" + " btn.className=b(payload)?'mode-active':'';" + " } else if (type=='c') {" + /* consume */ + " } else if (type=='x') {" + " playlist.innerHTML='';" + " } else if (type=='A') {" + " c(payload, true);" + " } else if (type=='a') {" + " c(payload, false);" + " } else if (type=='C') {" + " const t=document.querySelector('.controls>p>a');" + " t.innerText = payload.replace(/.*\\//, '');" + " cur();" + " } else {" + " console.log('unknown:',t);" + " }" + "};" + "function w(){" + " ws = new WebSocket((location.protocol=='http:'?'ws://':'wss://')" + " + location.host + '/ws');" + " ws.addEventListener('open', () => console.log('ws: connected'));" + " ws.addEventListener('close', () => console.log('ws: closed'));" + " ws.addEventListener('message', e => d(e.data))" + "};" + "w();" "cur();" - "document.querySelector('.controls a').addEventListener('click',cur)"; + "document.querySelector('.controls a').addEventListener('click',cur);" + "document.querySelectorAll('form').forEach(f => {" + " f.action='/a/'+f.getAttribute('action');" + " f.addEventListener('submit', e => {" + " e.preventDefault();" + " const fd = new FormData(f);" + " if (e.submitter && e.submitter.value && e.submitter.value != '')" + " fd.append(e.submitter.name, e.submitter.value);" + " fetch(f.action, {" + " method:'POST'," + " body: new URLSearchParams(fd)" + " })" + " .catch(x => console.log('failed to submit form:', x));" + " });" + "});"; const char *foot = ""; @@ -234,17 +309,109 @@ url_decode(char *url) return (0); } +static int +dispatch_event(const char *msg) +{ + struct client *clt; + size_t len; + int ret = 0; + + len = strlen(msg); + TAILQ_FOREACH(clt, &clients, clients) { + if (!clt->ws || clt->done || clt->err) + continue; + + if (ws_compose(clt, WST_TEXT, msg, len) == -1) + ret = -1; + + ev_add(clt->bio.fd, POLLIN|POLLOUT, client_ev, clt); + } + + return (ret); +} + +static int +dispatch_event_status(void) +{ + const char *status; + char buf[PATH_MAX + 2]; + int r; + + switch (player_status.status) { + case STATE_STOPPED: status = "stopped"; break; + case STATE_PLAYING: status = "playing"; break; + case STATE_PAUSED: status = "paused"; break; + default: status = "unknown"; + } + + r = snprintf(buf, sizeof(buf), "S:%s", status); + if (r < 0 || (size_t)r >= sizeof(buf)) { + log_warn("snprintf"); + return -1; + } + dispatch_event(buf); + + r = snprintf(buf, sizeof(buf), "r:%s", + player_status.mode.repeat_one == MODE_ON ? "on" : "off"); + if (r < 0 || (size_t)r >= sizeof(buf)) { + log_warn("snprintf"); + return -1; + } + dispatch_event(buf); + + r = snprintf(buf, sizeof(buf), "R:%s", + player_status.mode.repeat_all == MODE_ON ? "on" : "off"); + if (r < 0 || (size_t)r >= sizeof(buf)) { + log_warn("snprintf"); + return -1; + } + dispatch_event(buf); + + r = snprintf(buf, sizeof(buf), "c:%s", + player_status.mode.consume == MODE_ON ? "on" : "off"); + if (r < 0 || (size_t)r >= sizeof(buf)) { + log_warn("snprintf"); + return -1; + } + dispatch_event(buf); + + r = snprintf(buf, sizeof(buf), "C:%s", player_status.path); + if (r < 0 || (size_t)r >= sizeof(buf)) { + log_warn("snprintf"); + return -1; + } + dispatch_event(buf); + + return 0; +} + +static int +dispatch_event_track(struct player_status *ps) +{ + char p[PATH_MAX + 2]; + int r; + + r = snprintf(p, sizeof(p), "%c:%s", + ps->status == STATE_PLAYING ? 'A' : 'a', ps->path); + if (r < 0 || (size_t)r >= sizeof(p)) + return (-1); + + return dispatch_event(p); +} + static void imsg_dispatch(int fd, int ev, void *d) { static ssize_t off; static int off_found; + char seekmsg[128]; struct imsg imsg; struct player_status ps; struct player_event event; const char *msg; ssize_t n; size_t datalen; + int r; if (ev & (POLLIN|POLLHUP)) { if ((n = imsg_read(&ibuf)) == -1 && errno != EAGAIN) @@ -306,6 +473,14 @@ imsg_dispatch(int fd, int ev, void *d) case IMSG_CTL_SEEK: position = event.position; duration = event.duration; + r = snprintf(seekmsg, sizeof(seekmsg), + "s:%lld %lld", (long long)position, + (long long)duration); + if (r < 0 || (size_t)r >= sizeof(seekmsg)) { + log_warn("snprintf failed"); + break; + } + dispatch_event(seekmsg); break; default: @@ -316,6 +491,10 @@ imsg_dispatch(int fd, int ev, void *d) case IMSG_CTL_SHOW: if (datalen == 0) { + if (playlist_tmp.len == 0) { + dispatch_event("x:"); + off = -1; + } playlist_swap(&playlist_tmp, off); memset(&playlist_tmp, 0, sizeof(playlist_tmp)); off = 0; @@ -327,6 +506,9 @@ imsg_dispatch(int fd, int ev, void *d) memcpy(&ps, imsg.data, sizeof(ps)); if (ps.path[sizeof(ps.path) - 1] != '\0') fatalx("corrupted IMSG_CTL_SHOW"); + if (playlist_tmp.len == 0) + dispatch_event("x:"); + dispatch_event_track(&ps); playlist_push(&playlist_tmp, ps.path); if (ps.status == STATE_PLAYING) off_found = 1; @@ -341,6 +523,7 @@ imsg_dispatch(int fd, int ev, void *d) if (player_status.path[sizeof(player_status.path) - 1] != '\0') fatalx("corrupted IMSG_CTL_STATUS"); + dispatch_event_status(); break; } } @@ -414,7 +597,7 @@ render_controls(struct client *clt) " enctype='"FORM_URLENCODED"'>") == -1 || http_writes(clt, "") == -1 || - http_fmt(clt, "", playing ? "pause" : "play", playing ? ICON_PAUSE : ICON_PLAY) == -1 || http_writes(clt, "", ac) == -1 || - http_fmt(clt, "" + http_fmt(clt, "" ICON_REPEAT_ONE"", oc) == -1 || http_writes(clt, "") == -1 || http_writes(clt, "") == -1) @@ -489,7 +672,10 @@ route_jump(struct client *clt) if (!found) goto badreq; - http_reply(clt, 302, "See Other", "/"); + if (!strncmp(clt->req.path, "/a/", 2)) + http_reply(clt, 200, "OK", "text/plain"); + else + http_reply(clt, 302, "See Other", "/"); return; badreq: @@ -532,7 +718,10 @@ route_controls(struct client *clt) if (!found) goto badreq; - http_reply(clt, 302, "See Other", "/"); + if (!strncmp(clt->req.path, "/a/", 2)) + http_reply(clt, 200, "OK", "text/plain"); + else + http_reply(clt, 302, "See Other", "/"); return; badreq: @@ -574,12 +763,65 @@ route_mode(struct client *clt) if (!found) goto badreq; - http_reply(clt, 302, "See Other", "/"); + if (!strncmp(clt->req.path, "/a/", 2)) + http_reply(clt, 200, "OK", "text/plain"); + else + http_reply(clt, 302, "See Other", "/"); return; badreq: http_reply(clt, 400, "Bad Request", "text/plain"); http_writes(clt, "Bad Request.\n"); +} + +static void +route_handle_ws(struct client *clt) +{ + struct buffer *rbuf = &clt->bio.rbuf; + int type; + size_t len; + + if (ws_read(clt, &type, &len) == -1) { + if (errno != EAGAIN) { + log_warn("ws_read"); + clt->done = 1; + } + return; + } + + switch (type) { + case WST_PING: + ws_compose(clt, WST_PONG, rbuf->buf, len); + break; + case WST_TEXT: + /* log_info("<<< %.*s", (int)len, rbuf->buf); */ + break; + case WST_CLOSE: + /* TODO send a close too (ack) */ + clt->done = 1; + break; + default: + log_info("got unexpected ws frame type 0x%02x", type); + break; + } + + buf_drain(rbuf, len); +} + +static void +route_init_ws(struct client *clt) +{ + if (!(clt->req.flags & (R_CONNUPGR|R_UPGRADEWS|R_WSVERSION)) || + clt->req.secret == NULL) { + http_reply(clt, 400, "Bad Request", "text/plain"); + http_writes(clt, "Invalid websocket handshake.\r\n"); + return; + } + + clt->ws = 1; + clt->done = 0; + clt->route = route_handle_ws; + http_reply(clt, 101, "Switching Protocols", NULL); } static void @@ -609,10 +851,17 @@ route_dispatch(struct client *clt) route_fn route; } routes[] = { { METHOD_GET, "/", &route_home }, + { METHOD_POST, "/jump", &route_jump }, { METHOD_POST, "/ctrls", &route_controls }, { METHOD_POST, "/mode", &route_mode }, + { METHOD_POST, "/a/jump", &route_jump }, + { METHOD_POST, "/a/ctrls", &route_controls }, + { METHOD_POST, "/a/mode", &route_mode }, + + { METHOD_GET, "/ws", &route_init_ws }, + { METHOD_GET, "/style.css", &route_assets }, { METHOD_GET, "/app.js", &route_assets }, @@ -693,6 +942,7 @@ client_ev(int fd, int ev, void *d) err: ev_del(fd); + TAILQ_REMOVE(&clients, clt, clients); http_free(clt); } @@ -714,6 +964,8 @@ web_accept(int psock, int ev, void *d) return; } + TAILQ_INSERT_TAIL(&clients, clt, clients); + client_ev(sock, POLLIN, clt); return; } @@ -738,6 +990,7 @@ main(int argc, char **argv) int ch, v, amused_sock, fd; int verbose = 0; + TAILQ_INIT(&clients); setlocale(LC_ALL, NULL); log_init(1, LOG_DAEMON); blob - /dev/null blob + 16fab184ad65d5ba6cbe6e2c5fa25f9b404af1f1 (mode 644) --- /dev/null +++ web/ws.c @@ -0,0 +1,240 @@ +/* + * This is free and unencumbered software released into the public domain. + * + * Anyone is free to copy, modify, publish, use, compile, sell, or + * distribute this software, either in source code form or as a compiled + * binary, for any purpose, commercial or non-commercial, and by any + * means. + * + * In jurisdictions that recognize copyright laws, the author or authors + * of this software dedicate any and all copyright interest in the + * software to the public domain. We make this dedication for the benefit + * of the public at large and to the detriment of our heirs and + * successors. We intend this dedication to be an overt act of + * relinquishment in perpetuity of all present and future rights to this + * software under copyright law. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR + * OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, + * ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ + +#include "config.h" + +#include +#include +#include +#include +#include +#include + +#include "bufio.h" +#include "http.h" +#include "ws.h" + +#define WS_GUID "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + +static int +tob64(unsigned char ch) +{ + if (ch < 26) + return ('A' + ch); + ch -= 26; + if (ch < 26) + return ('a' + ch); + ch -= 26; + if (ch < 10) + return ('0' + ch); + ch -= 10; + if (ch == 0) + return ('+'); + if (ch == 1) + return ('/'); + errno = EINVAL; + return (-1); +} + +static int +b64encode(unsigned char *in, size_t ilen, char *out, size_t olen) +{ + int r; + +#define SET(x) { \ + if ((r = tob64((x) & 0x3F)) == -1) \ + return (-1); \ + *out++ = r; \ +} + + while (ilen > 0) { + if (olen < 4) { + errno = ENOSPC; + return (-1); + } + olen -= 4; + + switch (ilen) { + case 1: + SET(in[0] >> 2); + SET(in[0] << 4); + *out++ = '='; + *out++ = '='; + ilen = 0; + break; + case 2: + SET(in[0] >> 2); + SET(in[0] << 4 | in[1] >> 4); + SET(in[1] << 2); + *out++ = '='; + ilen = 0; + break; + default: + SET(in[0] >> 2); + SET(in[0] << 4 | in[1] >> 4); + SET(in[1] << 2 | in[2] >> 6); + SET(in[2]); + ilen -= 3; + in += 3; + break; + } + } + +#undef SET + + if (olen < 1) { + errno = ENOSPC; + return (-1); + } + *out = '\0'; + return (0); +} + +int +ws_accept_hdr(const char *secret, char *out, size_t olen) +{ + SHA1_CTX ctx; + uint8_t hash[SHA1_DIGEST_LENGTH]; + + SHA1Init(&ctx); + SHA1Update(&ctx, secret, strlen(secret)); + SHA1Update(&ctx, WS_GUID, strlen(WS_GUID)); + SHA1Final(hash, &ctx); + + return (b64encode(hash, sizeof(hash), out, olen)); +} + +int +ws_read(struct client *clt, int *type, size_t *len) +{ + struct buffer *rbuf = &clt->bio.rbuf; + size_t i; + uint32_t mask; + uint8_t first, second, op, plen; + + *type = WST_UNKNOWN, *len = 0; + + if (rbuf->len < 2) { + errno = EAGAIN; + return (-1); + } + + memcpy(&first, &rbuf->buf[0], sizeof(first)); + memcpy(&second, &rbuf->buf[1], sizeof(second)); + + /* for the close message this doesn't seem to be the case... */ +#if 0 + /* the reserved bits must be zero, don't care about FIN */ + if ((first & 0x0E) != 0) { + errno = EINVAL; + return (-1); + } +#endif + + /* mask must be set for messages sent by the clients */ + if ((second >> 7) != 1) { + errno = EINVAL; + return (-1); + } + + op = first & 0x0F; + plen = second & 0x7F; + + /* don't support extended payload length for now */ + if (plen >= 126) { + errno = E2BIG; + return (-1); + } + + *len = plen; + + switch (op) { + case WST_CONT: + case WST_TEXT: + case WST_BINARY: + case WST_CLOSE: + case WST_PING: + *type = op; + break; + } + + if (rbuf->len < sizeof(first) + sizeof(second) + sizeof(mask) + plen) { + errno = EAGAIN; + return (-1); + } + + buf_drain(rbuf, 2); /* header */ + memcpy(&mask, rbuf->buf, sizeof(mask)); + buf_drain(rbuf, 4); + + /* decode the payload */ + for (i = 0; i < plen; ++i) + rbuf->buf[i] ^= mask >> (8 * (i % 4)); + + return (0); +} + +int +ws_compose(struct client *clt, int type, const void *data, size_t len) +{ + struct bufio *bio = &clt->bio; + uint16_t extlen = 0; + uint8_t first, second; + + first = (type & 0x0F) | 0x80; + + if (len < 126) + second = len; + else { + second = 126; + + /* + * for the extended length, the most significant bit + * must be zero. We could use the 64 bit field but + * it's a waste. + */ + if (len > 0x7FFF) { + errno = ERANGE; + return (-1); + } + extlen = htons(len); + } + + if (bufio_compose(bio, &first, 1) == -1 || + bufio_compose(bio, &second, 1) == -1) + goto err; + + if (extlen != 0 && bufio_compose(bio, &extlen, sizeof(extlen)) == -1) + goto err; + + if (bufio_compose(bio, data, len) == -1) + goto err; + + return (0); + + err: + clt->err = 1; + return (-1); +} blob - /dev/null blob + 9a3e4b238fb0f39f79513fadc36158087f5c15d0 (mode 644) --- /dev/null +++ web/ws.h @@ -0,0 +1,40 @@ +/* + * This is free and unencumbered software released into the public domain. + * + * Anyone is free to copy, modify, publish, use, compile, sell, or + * distribute this software, either in source code form or as a compiled + * binary, for any purpose, commercial or non-commercial, and by any + * means. + * + * In jurisdictions that recognize copyright laws, the author or authors + * of this software dedicate any and all copyright interest in the + * software to the public domain. We make this dedication for the benefit + * of the public at large and to the detriment of our heirs and + * successors. We intend this dedication to be an overt act of + * relinquishment in perpetuity of all present and future rights to this + * software under copyright law. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR + * OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, + * ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ + +enum { + WST_UNKNOWN = -1, + WST_CONT = 0x00, + WST_TEXT = 0x01, + WST_BINARY = 0x02, + WST_CLOSE = 0x08, + WST_PING = 0x09, + WST_PONG = 0x0A, +}; + +struct client; + +int ws_accept_hdr(const char *, char *, size_t); +int ws_read(struct client *, int *, size_t *); +int ws_compose(struct client *, int, const void *, size_t);