Serving from Browser

Browsers have a lot of built in features like the camera, microphone, local files, GPS, the clipboard, and a GPU. I thought it would be interesting to serve up functionality from within the browser. The original article may be read here.

For this, I experimented with a reverse proxy written in golang:

package main
import (
 “fmt”
 “io”
 “os”
 “log”
 “net/http”
 “strings”
 “sync”
 “math/rand”
 “time”
 “github.com/gorilla/websocket”
)
var (
 clientConn      *websocket.Conn
 clientRespChans sync.Map
 clientLock      sync.Mutex
)
func main() {
 if len(os.Args) < 2 {
  log.Fatal(”usage: proxy <port>”)
 }
 port := os.Args[1]
 if !strings.HasPrefix(port, “:”) {
  port = “:” + port
 }
 upgrader := websocket.Upgrader{
  CheckOrigin: func(r *http.Request) bool { return true },
 }
 http.HandleFunc(”/ws”, func(w http.ResponseWriter, r *http.Request) {
  conn, err := upgrader.Upgrade(w, r, nil)
  if err != nil {
   log.Printf(”upgrade error: %v”, err)
   return
  }
  clientLock.Lock()
  if clientConn != nil {
   clientConn.Close()
  }
  clientConn = conn
  clientLock.Unlock()
  log.Println(”[server] client connected”)
  for {
   _, msg, err := conn.ReadMessage()
   if err != nil {
    log.Printf(”[server] client disconnected: %v”, err)
    clientLock.Lock()
    clientConn = nil
    clientLock.Unlock()
    return
   }
   parts := strings.SplitN(string(msg), “\n\n”, 2)
   if len(parts) < 2 { continue }
   if ch, ok := clientRespChans.Load(parts[0]); ok {
    ch.(chan []byte) <- []byte(parts[1])
   }
  }
 })
 http.HandleFunc(”/”, func(w http.ResponseWriter, r *http.Request) {
  clientLock.Lock()
  conn := clientConn
  clientLock.Unlock()
  if conn == nil {
   http.Error(w, “No client connected”, 502)
   return
  }
  reqID := fmt.Sprintf(”%d-%d”, time.Now().UnixNano(), rand.Int63())
  var sb strings.Builder
  sb.WriteString(r.Method + “ “ + r.URL.RequestURI() + “\n”)
  for k, v := range r.Header {
   for _, vv := range v {
    sb.WriteString(k + “: “ + vv + “\n”)
   }
  }
  sb.WriteString(”\n”)
  io.Copy(&sb, r.Body)
  respChan := make(chan []byte, 1)
  clientRespChans.Store(reqID, respChan)
  defer clientRespChans.Delete(reqID)
  if err := conn.WriteMessage(websocket.TextMessage, []byte(reqID+”\n\n”+sb.String())); err != nil {
   http.Error(w, “Tunnel write error”, 502)
   return
  }
  select {
  case resp := <-respChan:
   writeResponse(w, resp)
  case <-time.After(30 * time.Second):
   http.Error(w, “Timeout”, 504)
  }
 })
 log.Printf(”listening on %s”, port)
 log.Fatal(http.ListenAndServe(port, nil))
}
func writeResponse(w http.ResponseWriter, resp []byte) {
 parts := strings.SplitN(string(resp), “\n\n”, 2)
 header := parts[0]
 body := “”
 if len(parts) > 1 { body = parts[1] }
 lines := strings.Split(header, “\n”)
 statusCode := 200
 if len(lines) > 0 {
  sp := strings.SplitN(lines[0], “ “, 3)
  if len(sp) >= 2 {
   fmt.Sscanf(sp[1], “%d”, &statusCode)
  }
 }
 for _, h := range lines[1:] {
  if h == “” { continue }
  kv := strings.SplitN(h, “: “, 2)
  if len(kv) == 2 { w.Header().Add(kv[0], kv[1]) }
 }
 w.WriteHeader(statusCode)
 w.Write([]byte(body))
}

This then is the reverse proxy that will forward http requests over websocket to a JavaScript client running in the browser. You can build it as follows:

go build -o proxy main.go

Then you can run it as follows:

./proxy 8080

Now a connection over websockets can be made to port 8080 and the proxy server will forward all http requests to that connection.

The next part is to write a simple vanilla JavaScript class:

class TunnelApp {
    constructor(serverUrl, clientPath) {
 this.serverUrl = serverUrl;
 this.clientPath = clientPath.endsWith(’/’) ? clientPath.slice(0, -1) : clientPath;
 this.routes = [];
 this.socket = new WebSocket(this.serverUrl);
 this.socket.binaryType = ‘arraybuffer’;
 this.socket.onopen = () => this.socket.send(`PATH:${this.clientPath}`);
 this.socket.onmessage = (event) => this.handleMessage(event.data);
    }
    _pathToRegex(path) {
 const pattern = path.replace(/\//g, ‘\\/’).replace(/\*/g, ‘(.*)’);
 return new RegExp(`^${pattern}$`);
    }
    get(path, handler) { this.routes.push({ method: ‘GET’, regex: this._pathToRegex(path), handler }); }
    post(path, handler) { this.routes.push({ method: ‘POST’, regex: this._pathToRegex(path), handler }); }
    async handleMessage(message) {
 const delimiterIndex = message.indexOf(’\n\n’);
 if (delimiterIndex === -1) return;
 const reqID = message.substring(0, delimiterIndex);
 const rawRequest = message.substring(delimiterIndex + 2);
 const [headerPart, ...bodyParts] = rawRequest.split(’\n\n’);
 const [method, fullPath] = headerPart.split(’\n’)[0].split(’ ‘);
 const [pathOnly, queryString] = fullPath.split(’?’);
 const params = {};
 if (queryString) {
     for (const part of queryString.split(’&’)) {
  const [k, v] = part.split(’=’);
  params[decodeURIComponent(k)] = decodeURIComponent(v ?? ‘’);
     }
 }
 let match = null;
 const route = this.routes.find(r => r.method === method && (match = pathOnly.match(r.regex)));
 const req = {
     method, url: fullPath,
     wildcardMatch: match ? match[1] : null,
     params,
     headers: {}, body: bodyParts.join(’\n\n’)
 };
 const res = this._createResponse(reqID);
 if (route) {
     await route.handler(req, res);
 } else {
     res.status(404).send(`Route not found: ${fullPath}`);
 }
    }
    _createResponse(reqID) {
 const self = this;
 let final = { status: 200, headers: { ‘Content-Type’: ‘text/plain’ }, body: ‘’, isBinary: false };
 return {
     status(c) { final.status = c; return this; },
     set(k, v) { final.headers[k] = v; return this; },
     send(body) {
  final.body = body;
  final.isBinary = body instanceof Uint8Array || body instanceof ArrayBuffer;
  if (typeof body === ‘object’ && !final.isBinary) {
      final.body = JSON.stringify(body);
      this.set(’Content-Type’, ‘application/json’);
  }
  self._transmit(reqID, final);
     }
 };
    }
    _transmit(reqID, res) {
 let head = `HTTP/1.1 ${res.status}\n`;
 for (const [k, v] of Object.entries(res.headers)) head += `${k}: ${v}\n`;
 const headPayload = `${reqID}\n\n${head}\n`;
 if (res.isBinary) {
     const hBuf = new TextEncoder().encode(headPayload);
     const body = res.body instanceof ArrayBuffer ? new Uint8Array(res.body) : res.body;
     const combined = new Uint8Array(hBuf.length + body.length);
     combined.set(hBuf); combined.set(body, hBuf.length);
     this.socket.send(combined);
 } else {
     this.socket.send(headPayload + res.body);
 }
    }
}

You can then instantiate this as follows:

const app = new TunnelApp(’ws://localhost:8080/ws’, ‘/’);

The proxy server should print out the following line indicating a connection has been established:

2026/03/20 19:16:55 [server] client connected

Next, in the browser, you can set up a “service handler”:

app.get(’/hello’, (req, res) => {
    res.status(201)
       .set(’X-Custom-Header’, ‘Custom-Value’)
       .send(”hello”)
});

You should then be able to call this via the proxy as follows:

curl http://localhost:8080/hello

And it will respond with a 201, custom header, and response body.

You can also access query parameters as follows:

app.post(’/add’, (req, res) => {
        let result=parseFloat(req.params.x)+parseFloat(req.params.y);
        res.send({”result”:result});
});

Finally, you can make use of browser specific functionality in the service handlers, for example taking a snapshot with the webcam:

app.get(’/snapshot’, async (req, res) => {
    const permission = await navigator.permissions.query({ name: ‘camera’ });
    if (permission.state === ‘denied’) {
        return res.status(403).send({ error: ‘Camera permission denied’ });
    }
    const stream = await navigator.mediaDevices.getUserMedia({ video: true });
    const video = document.createElement(’video’);
    video.srcObject = stream;
    await video.play();
    const canvas = document.createElement(’canvas’);
    canvas.width = video.videoWidth;
    canvas.height = video.videoHeight;
    canvas.getContext(’2d’).drawImage(video, 0, 0);
    stream.getTracks().forEach(t => t.stop());
    const blob = await new Promise(resolve => canvas.toBlob(resolve, ‘image/jpeg’));
    const buffer = await blob.arrayBuffer();
    res.set(’Content-Type’, ‘image/jpeg’)
       .send(new Uint8Array(buffer));
});

Comments

Popular Posts