Getting Started with NDNts in Node.js

This article shows how to get started with NDNts, Named Data Networking (NDN) libraries for the modern web. In particular, it demonstrates how to write a producer and a consumer application in Node.js using JavaScript programming language, and transmit a few Interest and Data packets via NFD forwarder on the local machine.

Prepare the System

This guide is written for Ubuntu 18.04 operating system. If you have a Windows PC, you can enable Windows Subsystem for Linux and install Ubuntu 18.04 from the Microsoft Store. If you have a Macbook or a Linux machine other than Ubuntu 18.04, you can install Vagrant, and create a virtual machine using bento/ubuntu-18.04 template. All steps below should be executed inside Ubuntu 18.04 environment.

To use NDNts, you must have Node.js. As of this writing, NDNts works best with Node.js 14.x, and you should install that version. The easiest way to install Node.js is through Node Version Manager (nvm). To install nvm and then install Node.js, type the following commands in Ubuntu 18.04 terminal:

$ wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.35.3/install.sh | bash
$ nvm install 14
Now using node v14.2.0 (npm v6.14.4)

You also need the NDN Forwarding Daemon (NFD) and ndnpeek program. They can be installed with the following commands:

$ sudo add-apt-repository ppa:named-data/ppa
$ sudo apt install nfd ndnpeek

Now create a directory for our new project, and then open a code editor such as Visual Studio Code at this directory. If you have installed Ubuntu 18.04 from Microsoft Store, you can type explorer.exe . at Ubuntu 18.04 terminal to open Windows Explorer, and then right click to select Open with Code. If you have installed Ubuntu 18.04 as a Vagrant virtual machine, you can create the project directory under /vagrant, and the directory should show up alongside where you placed the Vagrantfile.

Install NDNts

NDNts is a group of Node.js packages. Although they have been published to NPM registry, NPM releases happens rather infrequently. Thus, it's best to install nightly builds to obtain the latest features.

To install NDNts nightly builds, create a file package.json in the project directory, and then paste the following content:

{
  "private": true,
  "type": "module",
  "dependencies": {
    "@ndn/cli-common": "https://ndnts-nightly.ndn.today/cli-common.tgz",
    "@ndn/endpoint": "https://ndnts-nightly.ndn.today/endpoint.tgz",
    "@ndn/packet": "https://ndnts-nightly.ndn.today/packet.tgz"
  }
}

Then, type npm install at the terminal. A minute later, all packages and their dependencies are installed automatically.

The Producer

Create a file producer.mjs and paste the following content:

import { openUplinks } from "@ndn/cli-common";
import { Endpoint } from "@ndn/endpoint";
import { Name, Data } from "@ndn/packet";

(async () => {
  // openUplinks() creates a connection to the "uplink", in this case the local NFD forwarder.
  // It returns a Promise, so remember to await it.
  await openUplinks();

  // Endpoint is a centerpiece of NDNts. You can use it to create a producer or a consumer.
  // It is similar to, but more powerful than, "face" in other NDN libraries.
  // You'll soon see some of its powers.
  const endpoint = new Endpoint();

  // endpoint.produce() creates a producer.
  // The first argument is the name prefix.
  // The second argument is a callback function that is invoked for each incoming Interest;
  // this must be an async function that returns a Promise.
  endpoint.produce(new Name("/add"), async (interest) => {
    console.log(`Got Interest ${interest.name}`);
    // This producer is a calculator. It expects Interest name to have three
    // components: "add", x, and y. If it's not, reject the Interest.
    if (interest.name.length !== 3) {
      console.log("Wrong name length.");
      return;
    }

    // Extract x and y numbers, then compute the sum.
    const x = Number.parseInt(interest.name.at(1).text);
    const y = Number.parseInt(interest.name.at(2).text);
    const sum = x + y;
    console.log(`${x} + ${y} = ${sum}`);

    // Make a Data packet that has the same name as the Interest.
    const data = new Data(interest.name);
    data.freshnessPeriod = 1000;
    data.content = new TextEncoder().encode(`${sum}\n`);

    // Sending the Data is as simple as returning it from the function.
    return data;
  },
  // options
  );
})();

Run this script:

$ NDNTS_NFDREG=1 node ./producer.mjs

Open another terminal window, and run ndnpeek (a simple consumer):

$ ndnpeek -fp /add/12345678/87654321
99999999

It prints the Data payload, which is the sum of the two numbers passed into the name.

The Consumer

Create a file consumer.mjs and paste the following content:

import { openUplinks, closeUplinks } from "@ndn/cli-common";
import { Endpoint } from "@ndn/endpoint";
import { Interest } from "@ndn/packet";

// Parse x and y from command line arguments.
const [x, y] = process.argv.slice(2);

(async () => {
  // Connect to NFD.
  await openUplinks();

  const endpoint = new Endpoint();

  // Make an Interest packet, asking the producer to compute x+y.
  const interest = new Interest(`/add/${x}/${y}`);
  interest.mustBeFresh = true;

  try {
    // Send the Interest, and wait for Data to come back.
    const data = await endpoint.consume(interest);

    // Print the Data payload.
    process.stdout.write(data.content);
  } catch (err) {
    // In case of Data retrieval failure, show what went wrong.
    console.warn(err);
  }

  // Disconnect from NFD, so that Node.js can exit normally.
  closeUplinks();
})();

Keep the producer running in the first terminal window. In the second terminal, run this consumer script, sending an Interest for 1 + 1:

$ node ./consumer.mjs 1 1
2

This is how you compute 1 + 1 using NDN.

Consumer Retransmissions

Now let me show you one of the powers in Endpoint: consumer retransmissions. As you may already know, NDN's communication model is receiver-driver: the network provides a best-effort service, while the consumer is ultimately responsible for retransmitting its Interests if the Data does not arrive. In other libraries, your application may have to deal with retransmissions yourself, while in NDNts, retransmissions can be enabled with a single flag.

Go to the first terminal window, press CTRL+C to stop the producer. Then, run the consumer again, asking for 1 + 2:

$ node ./consumer.mjs 1 2
Error: Interest rejected: expire @consume(/8=add/8=1/8=2)

You got an error: the Interest has been rejected, because the producer isn't running.

To enable retransmissions, change endpoint.consume line of the consumer script as:

const data = await endpoint.consume(interest, { retx: 100 });

This allows NDNts to retransmit the Interest for up to 100 times, if the initial Interest does not receive a reply.

After this modification, run the consumer. This time, add NDNTS_PKTTRACE=1 environment variable, so that you can see the packet exchanges.

$ NDNTS_PKTTRACE=1 node ./consumer.mjs 1 2

As you can see from the log messages, the consumer does not fail right away, but keeps retransmitting the Interest. While the consumer is still running, go to the first terminal window and start the producer again. After that, the consumer should receive the Data and exit.

$ NDNTS_PKTTRACE=1 node ./consumer.mjs 1 2
+Face Unix(/run/nfd.sock)
Unix(/run/nfd.sock) +Prefix /
+Face consume(/8=add/8=1/8=2)
consume(/8=add/8=1/8=2) >I /8=add/8=1/8=2[F]
Unix(/run/nfd.sock) <I /8=add/8=1/8=2[F]
consume(/8=add/8=1/8=2) >I /8=add/8=1/8=2[F]
Unix(/run/nfd.sock) <I /8=add/8=1/8=2[F]
Unix(/run/nfd.sock) >D /8=add/8=1/8=2
consume(/8=add/8=1/8=2) <D /8=add/8=1/8=2
3
-Face Unix(/run/nfd.sock)
-Face consume(/8=add/8=1/8=2)

A word on /8=add/8=1/8=2: this is the same name as /add/1/2, written in canonical format. NDN Packet Format v0.3 introduces typed name component, which allows a name component to have a type number between 1 and 65535. When a name is written in its canonical format, the type number appears in each component. NDNts by default prints names in the canonical format, although the AltUriFormat class can be used to print in the alternate format such as /add/1/2. The Name constructor, however, only accepts canonical format, with a special case that allows omitting type number "8".

Producer Parallelism

Another powerful feature of Endpoint, on the producer side, is controlling parallelism of the producer callback function. To demonstrate this effect, we'd make the "x + y" function a bit slower. In the producer script, insert this line just above return data;:

// Simulate 100 milliseconds processing delay.
await new Promise((r) => setTimeout(r, 100));

We also need a consumer that sends many requests at once, bulk.js:

import { openUplinks, closeUplinks } from "@ndn/cli-common";
import { Endpoint } from "@ndn/endpoint";
import { Interest } from "@ndn/packet";

(async () => {
  await openUplinks();
  const endpoint = new Endpoint();

  const interests = [];
  for (let i = 0; i < 100; ++i) {
    const x = Math.floor(Math.random() * 1000000);
    const y = Math.floor(Math.random() * 1000000);
    const interest = new Interest(`/add/${x}/${y}`);
    interest.mustBeFresh = true;
    interests.push(interest);
  }

  const t0 = Date.now();
  const settled = await Promise.allSettled(
    interests.map((interest) => endpoint.consume(interest, { retx: 5 }))
  );
  const t1 = Date.now();
  const nFulfilled = settled.filter(({ status }) => status === "fulfilled").length;
  console.log(`${nFulfilled} fulfilled in ${t1 - t0}ms`);

  closeUplinks();
})();

Start the producer in the first terminal, and run node ./bulk.js in the second terminal. You would see something like:

$ node ./bulk.mjs
100 fulfilled in 10524ms

All 100 requests were successful, and they took a total of 10.5 seconds.

Now we modify the producer script, replace // options line near the end with:

{ concurrency: 10 }

This tells endpoint to invoke at most 10 instances of the producer callback function in parallel.

Run the bulk consumer again:

$ node ./bulk.mjs
100 fulfilled in 1098ms

Much faster: 100 requests took a total of 1.1 seconds.

As of this writing, this feature is unique to NDNts. Other NDN libraries either let the application process one Interest at a time, or pass all requests to the application callback and you have to handle parallelism yourself.

Conclusion

This article is a getting started guide of NDNts libraries in Node.js environment. If you followed along, you have installed NDNts in Node.js 14.x, and learned how to write a producer and consumer using the Endpoint type. You also witnessed two powerful features of NDNts Endpoint type.

Tags: