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.goThen you can run it as follows:
./proxy 8080Now 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 connectedNext, 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/helloAnd 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
Post a Comment