lnd.xprv/docs/rest/websockets.md
Oliver Gugger 993167f14e
docs+lnrpc: enable bi-directional WebSockets
The grpc-gateway library that is used to transform REST calls into gRPC
uses a different method for reading a request body stream depending on
whether the RPC is a request-streaming one or not. We can't really find
out what kind of RPC the user is calling at runtime, so we add a new
parameter to the proxy that lists all request-streaming RPC calls.
In any case the client _has_ to send one request message initially to
kick off the request processing. Normally this can just be an empty
message. This can lead to problems if that empty message is not
expected by the gRPC server. But for the currently existing two
client-streaming RPCs this will only trigger a warning
(HTLC interceptor) or be ignored (channel acceptor).
2021-04-29 10:39:12 +02:00

5.7 KiB

WebSockets with lnd's REST API

This document describes how streaming response REST calls can be used correctly by making use of the WebSocket API.

As an example, we are going to write a simple JavaScript program that subscribes to lnd's block notification RPC.

The WebSocket will be kept open as long as lnd runs and JavaScript program isn't stopped.

Browser environment

When using WebSockets in a browser, there are certain security limitations of what header fields are allowed to be sent. Therefore, the macaroon cannot just be added as a Grpc-Metadata-Macaroon header field as it would work with normal REST calls. The browser will just ignore that header field and not send it.

Instead we have added a workaround in lnd's WebSocket proxy that allows sending the macaroon as a WebSocket "protocol":

const host = 'localhost:8080'; // The default REST port of lnd, can be overwritten with --restlisten=ip:port
const macaroon = '0201036c6e6402eb01030a10625e7e60fd00f5a6f9cd53f33fc82a...'; // The hex encoded macaroon to send
const initialRequest = { // The initial request to send (see API docs for each RPC).
    hash: "xlkMdV382uNPskw6eEjDGFMQHxHNnZZgL47aVDSwiRQ=", // Just some example to show that all `byte` fields always have to be base64 encoded in the REST API.
    height: 144,
}

// The protocol is our workaround for sending the macaroon because custom header
// fields aren't allowed to be sent by the browser when opening a WebSocket.
const protocolString = 'Grpc-Metadata-Macaroon+' + macaroon;

// Let's now connect the web socket. Notice that all WebSocket open calls are
// always GET requests. If the RPC expects a call to be POST or DELETE (see API
// docs to find out), the query parameter "method" can be set to overwrite.
const wsUrl = 'wss://' + host + '/v2/chainnotifier/register/blocks?method=POST';
let ws = new WebSocket(wsUrl, protocolString);
ws.onopen = function (event) {
    // After the WS connection is establishes, lnd expects the client to send the
    // initial message. If an RPC doesn't have any request parameters, an empty
    // JSON object has to be sent as a string, for example: ws.send('{}')
    ws.send(JSON.stringify(initialRequest));
}
ws.onmessage = function (event) {
    // We received a new message.
    console.log(event);

    // The data we're really interested in is in data and is always a string
    // that needs to be parsed as JSON and always contains a "result" field:
    console.log("Payload: ");
    console.log(JSON.parse(event.data).result);
}
ws.onerror = function (event) {
    // An error occured, let's log it to the console.
    console.log(event);
}

Node.js environment

With Node.js it is a bit easier to use the streaming response APIs because we can set the macaroon header field directly. This is the example from the API docs:

// --------------------------
// Example with websockets:
// --------------------------
const WebSocket = require('ws');
const fs = require('fs');
const macaroon = fs.readFileSync('LND_DIR/data/chain/bitcoin/simnet/admin.macaroon').toString('hex');
let ws = new WebSocket('wss://localhost:8080/v2/chainnotifier/register/blocks?method=POST', {
  // Work-around for self-signed certificates.
  rejectUnauthorized: false,
  headers: {
    'Grpc-Metadata-Macaroon': macaroon,
  },
});
let requestBody = { 
  hash: "<byte>",
  height: "<int64>",
}
ws.on('open', function() {
    ws.send(JSON.stringify(requestBody));
});
ws.on('error', function(err) {
    console.log('Error: ' + err);
});
ws.on('message', function(body) {
    console.log(body);
});
// Console output (repeated for every message in the stream):
//  { 
//      "hash": <byte>, 
//      "height": <int64>, 
//  }

Request-streaming RPCs

Starting with lnd v0.13.0-beta all RPCs can be used through REST, even those that are fully bi-directional (e.g. the client can also send multiple request messages to the stream).

Example:

As an example we show how one can use the bi-directional channel acceptor RPC. Through that RPC each incoming channel open request (another peer opening a channel to our node) will be passed in for inspection. We can decide programmatically whether to accept or reject the channel.

// --------------------------
// Example with websockets:
// --------------------------
const WebSocket = require('ws');
const fs = require('fs');
const macaroon = fs.readFileSync('LND_DIR/data/chain/bitcoin/simnet/admin.macaroon').toString('hex');
let ws = new WebSocket('wss://localhost:8080/v1/channels/acceptor?method=POST', {
  // Work-around for self-signed certificates.
  rejectUnauthorized: false,
  headers: {
    'Grpc-Metadata-Macaroon': macaroon,
  },
});
ws.on('open', function() {
    // We always _need_ to send an initial message to kickstart the request.
    // This empty message will be ignored by the channel acceptor though, this
    // is just for telling the grpc-gateway library that it can forward the
    // request to the gRPC interface now. If this were an RPC where the client
    // always sends the first message (for example the streaming payment RPC
    // /v1/channels/transaction-stream), we'd simply send the first "real"
    // message here when needed.
    ws.send('{}');
});
ws.on('error', function(err) {
    console.log('Error: ' + err);
});
ws.on('ping', function ping(event) {
   console.log('Received ping from server: ' + JSON.stringify(event)); 
});
ws.on('message', function incoming(event) {
    console.log('New channel accept message: ' + event);
    const result = JSON.parse(event).result;
    
    // Accept the channel after inspecting it.
    ws.send(JSON.stringify({accept: true, pending_chan_id: result.pending_chan_id}));
});