├── .eslintrc.json ├── .gitignore ├── NOTES.md ├── README.md ├── cache ├── node-head.json └── subscription-heads.json ├── examples ├── publish.js ├── subscribe.js └── utils │ └── forceCreateRoot.js ├── package.json ├── src ├── index.js ├── message-cache.js ├── node-cache.js ├── node.js ├── publish.js ├── subscription-cache.js ├── subscription.js └── utils │ ├── config.js │ ├── errors.js │ ├── ipfs.js │ └── log.js └── test ├── blackbox.spec.js ├── index.spec.js ├── node.spec.js ├── publish.spec.js ├── subscription.spec.js └── utils └── ipfs-control.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base", 3 | "globals": { 4 | "expect": true 5 | }, 6 | "rules": { 7 | "semi": ["error", "never"], 8 | "no-param-reassign": 0, 9 | "no-underscore-dangle": 0 10 | }, 11 | "env": { 12 | "browser": true, 13 | "node": true, 14 | "mocha": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | npm*.log 3 | npm*.debug -------------------------------------------------------------------------------- /NOTES.md: -------------------------------------------------------------------------------- 1 | // Note about Nomad Message Structure in IPFS 2 | // 3 | // Nomad stores messages as a linked list of IPLD objects. Each 4 | // object has an empty data property and two links: 5 | // 6 | // { 7 | // data: '', 8 | // links: [ 9 | // { source: prev ... } 10 | // { source: data ... } 11 | // ] 12 | // } 13 | // 14 | // data: references an IPLD object that is the head of a unixfs object that is the message data 15 | // prev: references the previous Nomad message object -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nomad 2 | 3 | > A decentralized pubsub messaging platform built on IPFS and libp2p 4 | 5 | ## Overview 6 | Nomad is a decentralized system for subscribing to, processing, and publishing data in the form of ordered message streams. Nomad is decentralized: there are no message brokers through which messages pass. Nomad uses [IPFS](http://ipfs.io) to create a peer-to-peer network of nodes that routes messages from publisher to subscriber. Streams are published by nodes which are identified by a public key hash, making Nomad a permissionless system. Anyone can create a new node, subscribe to existing streams, and publish a stream without signing up for any proprietary service. 7 | 8 | ## Why Nomad? 9 | Data is a beautiful thing, but it's too hard to share live data, to process data and share real time insights, or to connect visualizations to live data. Nomad draws from the likes of stream processing systems like Apache [Kafka](https://kafka.apache.org/) but adds decentralization to create a global system of durable but lightweight data pipes that anyone can build on top of. 10 | 11 | ## Get Started 12 | 13 | ### Install 14 | Nomad uses IPFS under the hood. Head to the IPFS distributions page [here](https://dist.ipfs.io/#go-ipfs) and download the binary for your platform. 15 | 16 | After downloading, untar the archive and run the included ```install.sh```: 17 | ```console 18 | > tar xvfz go-ipfs.tar.gz 19 | > cd go-ipfs 20 | > ./install.sh 21 | ``` 22 | 23 | Test that IPFS installed successfully: 24 | ```console 25 | > ipfs help 26 | USAGE 27 | ipfs - Global p2p merkle-dag filesystem. 28 | ``` 29 | 30 | Install the Nomad npm module: 31 | > (If you don't have [Node.js](https://nodejs.org/en/download/), install it first.) 32 | 33 | ```console 34 | npm install --save nomad-stream 35 | ``` 36 | 37 | ### Write some code to subscribe to a stream 38 | Subscribe to an existing nomad stream and log its messages to the console: 39 | ```javascript 40 | const Nomad = require('nomad-stream') 41 | const nomad = new Nomad() 42 | 43 | nomad.subscribe(['QmP2akn...'], function(message) { 44 | console.log(message.message) 45 | }) 46 | ``` 47 | The string ```QmP2akn...``` is the unique id of a publishing Nomad node to which this node is subscribing. The id is the hash of the public key of the node. 48 | 49 | Save your code as ```subscribe.js``` 50 | 51 | ### Start subscribing 52 | Start IPFS: 53 | ```console 54 | > ipfs daemon 55 | ``` 56 | 57 | In a new terminal window, start subscribing: 58 | ```console 59 | > node subscribe.js 60 | ``` 61 | 62 | > It may take a minute or more before you see any messages. 63 | 64 | ### 🔥🚀 65 | You just created your first node! What's next? Browse the docs for the complete API. Create a node that publishes something interesting to the world. 66 | 67 | ### Troubleshooting 68 | 69 | Not seeing any messages? Make sure you started IPFS: 70 | ```console 71 | ipfs daemon 72 | ``` 73 | 74 | Still having trouble? Kill Node.js, turn on verbose logging, and try again: 75 | ```console 76 | > export DEBUG="nomad*" 77 | > node subscribe.js 78 | ``` 79 | 80 | ## Full API 81 | 82 | ### Initializing 83 | Require module and create a new instance: 84 | ```javascript 85 | const Nomad = require('nomad-stream') 86 | const nomad = new Nomad() 87 | ``` 88 | 89 | ### Subscribing 90 | Subscribe to one or more nodes' streams: 91 | ```javascript 92 | nomad.subscribe(array, callback) 93 | ``` 94 | 95 | ```array``` is an array of node ids to subscribe to. ```callback``` is called once when a new message arrives for any subscribed stream. Callback is passed a single argument which is an object: 96 | ```javascript 97 | {id, link, message} 98 | ``` 99 | 100 | ```id``` is the node id of the node that published the message, ```link``` is the hash of the IPFS IPLD object that contains the message data, ```message``` is the message string. 101 | 102 | Unsubscribe to a node's stream: 103 | ```javascript 104 | nomad.unsubscribe(nodeID) 105 | ``` 106 | 107 | ### Publishing 108 | Prepare a node to publish: 109 | ```javascript 110 | nomad.prepareToPublish().then(function(n) { 111 | const nomadInstance = n 112 | }) 113 | ``` 114 | Returns a promise that resolves to an instance object used to publish messages. 115 | 116 | Publish a root message: 117 | ```javascript 118 | instance.publishRoot(messageString) 119 | ``` 120 | Publishes a root message to subscribers, which is the first message in the stream of messages. The first time a node is run, publish root must be called once before ```publish``` is called. Published messages must be a string. To published structured data, data needs to be stringified first using ```JSON.stringify```. Returns a promise. 121 | 122 | Publish a message to subscribers: 123 | ```javascript 124 | instance.publish(messageString) 125 | ``` 126 | Publishes a message to subscribers. As with ```publishRoot``` the message must be a string. Returns a promise. 127 | 128 | ### Node identity 129 | A running node's id comes from the running instance of ipfs started via ```ipfs daemon```. A new identity can be created by either deleting an existing IPFS repo or setting ```IPFS_PATH``` and running ```ipfs init``` again. For details see the IPFS [command line docs](https://ipfs.io/docs/commands/). 130 | 131 | ## Caveats 132 | Nomad is alpha software and depends on IPFS which is also alpha software. Things may break at any time. Nomad does not currently include features that support node fault tolerance, but they're in the works! 133 | 134 | ## Contribute 135 | 136 | Feel free to dive in! [Open an issue](https://github.com/ideo-colab/nomad/issues/new) or submit PRs. 137 | 138 | ## License 139 | 140 | MIT (c) IDEO CoLab 141 | -------------------------------------------------------------------------------- /cache/node-head.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IDEO-coLAB/nomad/81441bbb58f6b7a57e5cf91f8f3ef520e5d88034/cache/node-head.json -------------------------------------------------------------------------------- /cache/subscription-heads.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IDEO-coLAB/nomad/81441bbb58f6b7a57e5cf91f8f3ef520e5d88034/cache/subscription-heads.json -------------------------------------------------------------------------------- /examples/publish.js: -------------------------------------------------------------------------------- 1 | const Node = require('./../src/node') 2 | 3 | const node = new Node() 4 | 5 | // const messages = [ 6 | // () => 'Hello world from the Nomad mothership', 7 | // () => `At the beep, the time is ${new Date().toString()}`, 8 | // () => '4, 8, 15, 16, 23, 42', 9 | // ] 10 | 11 | let idx = 0 12 | 13 | const createMessage = () => { 14 | // const idx = Math.floor(Math.random() * messages.length) 15 | // return (messages[idx]()) 16 | return `Message: ${idx++}` 17 | } 18 | 19 | console.log('TELL PUBLISH WHAT TO DO IN THE EXAMPLE FILE!') 20 | 21 | let instance = null 22 | node.prepareToPublish() 23 | // .then((n) => { 24 | // instance = n 25 | // // console.log('DEMO: CONNECTED!!!!') 26 | // return instance.publishRoot('ROOT MESSAGE') 27 | // }) 28 | // .catch(() => { 29 | // log.err('Error publishing root message') 30 | // }) 31 | // .then(() => { 32 | // console.log('READY') 33 | // console.log('DEMO: ROOT PUBLISHED!!!!') 34 | // setInterval(() => { 35 | // instance.publish(createMessage()) 36 | // }, 60000) 37 | // return node.publish(createMessage()) 38 | // }) 39 | // .catch((err) => { 40 | // // log.err('err') 41 | // // console.log('DEMO: CONNECT ERROR!!!!', e) 42 | // // console.log(e) 43 | // // return node.publish('Hey there, Gavin!') 44 | // }) 45 | // .then(() => { 46 | // // console.log('DEMO: PUBLLISHED!!!!', d) 47 | // }) 48 | .catch(() => { 49 | // console.log('DEMO: PUBLISH ERROR!!!!', e) 50 | }) 51 | -------------------------------------------------------------------------------- /examples/subscribe.js: -------------------------------------------------------------------------------- 1 | const Node = require('./../src/node') 2 | // const util = require('util') 3 | 4 | const node = new Node() 5 | 6 | const processMessages = (err, messages) => { 7 | if (messages) { 8 | // console.log('MESSAGES RECEIVED:\n', util.inspect(messages, {depth: null})) 9 | } 10 | if (err) { 11 | // console.log('MESSAGES ERR:\n', err) 12 | } 13 | // console.log('MESSAGES RECEIVED!!!!!\n') 14 | // do something with the messages 15 | } 16 | 17 | // subscribes to all sensors in subscriptions list, calls processMessages 18 | // callback when any subscription has a new message. Also calls processMessages 19 | // once with latest message. 20 | node.onMessage(processMessages) 21 | -------------------------------------------------------------------------------- /examples/utils/forceCreateRoot.js: -------------------------------------------------------------------------------- 1 | // Command line script to forcibly create a nomad message and publish under the 2 | // node's IPNS name regardless of what IPNS currently points to 3 | 4 | const Node = require('./../../src/node') 5 | const log = require('./../../src/utils/log') 6 | 7 | const node = new Node() 8 | 9 | const message = 'Assets to assets, dist to dist' 10 | 11 | let instance = null 12 | node.prepareToPublish(false) // prepare identity without syncing message head 13 | .then((n) => { 14 | instance = n 15 | log.info(`writing root message, '${message}'`) 16 | return instance.publishRoot(message) 17 | }) 18 | .catch((err) => { 19 | log.err(err) 20 | }) 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nomad-stream", 3 | "version": "0.0.8", 4 | "description": "A decentralized platform for open data streams built on IPFS", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "lint": "eslint .", 8 | "test": "DEBUG=nomad* mocha -t 120000 test/**/*.spec.js", 9 | "example:sub": "DEBUG=nomad* node examples/subscribe.js", 10 | "example:pub": "DEBUG=nomad* node examples/publish.js" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/IDEO-coLAB/nomad.git" 15 | }, 16 | "keywords": [ 17 | "sensors", 18 | "sensor", 19 | "network", 20 | "ipfs", 21 | "libp2p", 22 | "software", 23 | "sensor", 24 | "nodes", 25 | "nomad" 26 | ], 27 | "author": "Reid Williams ", 28 | "license": "MIT", 29 | "bugs": { 30 | "url": "https://github.com/IDEO-coLAB/nomad/issues" 31 | }, 32 | "homepage": "https://github.com/IDEO-coLAB/nomad#readme", 33 | "dependencies": { 34 | "bs58": "3.0.0", 35 | "chai": "3.5.0", 36 | "debug": "2.2.0", 37 | "eslint": "3.8.1", 38 | "eslint-config-airbnb-base": "8.0.0", 39 | "eslint-plugin-import": "1.16.0", 40 | "ipfs-api": "6.0.3", 41 | "ipfs-merkle-dag": "0.6.2", 42 | "lru-cache": "4.0.1", 43 | "multihashes": "0.2.2", 44 | "peer-id": "0.7.0", 45 | "pull-stream": "3.4.3", 46 | "q": "1.4.1", 47 | "ramda": "0.22.1", 48 | "save": "2.3.0", 49 | "stream-to-promise": "^2.2.0", 50 | "task-queue": "^1.0.2" 51 | }, 52 | "devDependencies": { 53 | "mocha": "^3.1.2" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const Node = require('./node') 2 | 3 | module.exports = Node 4 | -------------------------------------------------------------------------------- /src/message-cache.js: -------------------------------------------------------------------------------- 1 | const R = require('ramda') 2 | 3 | const log = require('./utils/log') 4 | 5 | const MODULE_NAME = 'MESSAGE_CACHE' 6 | 7 | const MAX_MESSAGE_STORE_SIZE = 5 8 | 9 | // Note: this is currently an in-process-memory cache 10 | 11 | // Class for a node to work with its subscription messages store 12 | // 13 | class MessageStore { 14 | constructor() { 15 | this.store = {} 16 | } 17 | 18 | // Get a message from the store for a key, or return the mosr recent 19 | // 20 | // @param {String} key (optional ) 21 | // 22 | // @return {Array} messages for the key || {Object} MessageStore 23 | // 24 | get(key) { 25 | const keyPassed = !R.isNil(key) 26 | const keyExists = R.has(key, this.store) 27 | 28 | if (keyPassed) { 29 | if (keyExists) { 30 | return this.store[key] 31 | } 32 | return [] 33 | } 34 | 35 | return this.store 36 | } 37 | 38 | // Update the store with new messages 39 | // 40 | // @param {String} key 41 | // @param {Object} message 42 | // @param {string} link (hash to look up the message on the network) 43 | // 44 | // @return {Object} MessageStore 45 | // 46 | put(key, message, link) { 47 | log.info(`${MODULE_NAME}: ${key}: Adding new message`) 48 | 49 | const keyExists = R.has(key, this.store) 50 | if (!keyExists) { 51 | this.store[key] = [] 52 | } 53 | 54 | const subscriptionStore = this.store[key] 55 | if (R.length(subscriptionStore) >= MAX_MESSAGE_STORE_SIZE) { 56 | // remove the last message from end 57 | subscriptionStore.pop() 58 | } 59 | 60 | // add the latest message to the front 61 | subscriptionStore.unshift({ 62 | link, 63 | message, 64 | }) 65 | 66 | log.info(`${MODULE_NAME}: ${key}: Message added`) 67 | return subscriptionStore 68 | } 69 | } 70 | 71 | // API 72 | // 73 | module.exports = new MessageStore() 74 | -------------------------------------------------------------------------------- /src/node-cache.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | 3 | const config = require('./utils/config') 4 | const log = require('./utils/log') 5 | 6 | const MODULE_NAME = 'NODE_CACHE' 7 | 8 | const CACHED_HEAD_PATH = config.path.cachedNodeHead 9 | 10 | // Create a new file to act as the cache for the node's head 11 | // 12 | // @return {Bool} 13 | // 14 | const initHeadCache = () => { 15 | fs.writeFileSync(CACHED_HEAD_PATH, '\r\n') 16 | return true 17 | } 18 | 19 | // Get the latest node head from the cache 20 | // 21 | // @return {Object || null} 22 | // 23 | module.exports.getHead = () => { 24 | let buffer 25 | let head 26 | 27 | // Ensure the cache file exists 28 | // create an empty one if it doesn't 29 | try { 30 | buffer = fs.readFileSync(CACHED_HEAD_PATH) 31 | } catch(err) { 32 | initHeadCache() 33 | } 34 | buffer = fs.readFileSync(CACHED_HEAD_PATH) 35 | 36 | // Ensure the file contains valid json 37 | try { 38 | head = JSON.parse(buffer.toString()) 39 | log.info(`${MODULE_NAME}: Head found`) 40 | } catch(err) { 41 | head = null 42 | log.info(`${MODULE_NAME}: No head found`) 43 | } 44 | 45 | return head 46 | } 47 | 48 | // Set the node head in a cache 49 | // 50 | // @return {Object || null} 51 | // 52 | module.exports.setHead = (head) => { 53 | try { 54 | fs.writeFileSync(CACHED_HEAD_PATH, JSON.stringify(head)) 55 | } catch(err) { 56 | initHeadCache() 57 | } 58 | fs.writeFileSync(CACHED_HEAD_PATH, JSON.stringify(head)) 59 | log.info(`${MODULE_NAME}: Head set`) 60 | return true 61 | } 62 | -------------------------------------------------------------------------------- /src/node.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const R = require('ramda') 3 | 4 | const Subscription = require('./subscription') 5 | const { publish, publishRoot } = require('./publish') 6 | const { getHead } = require('./node-cache') 7 | const log = require('./utils/log') 8 | const { id } = require('./utils/ipfs') 9 | const { passOrDie, NomadError } = require('./utils/errors') 10 | 11 | const MODULE_NAME = 'NODE' 12 | 13 | // Class: Node 14 | // 15 | // @param {Object} userConfig (Array of peerIds) 16 | // 17 | module.exports = class Node { 18 | constructor(userConfig = {}) { 19 | this.identity = null 20 | this.subscriptions = null 21 | this.head = getHead() 22 | } 23 | 24 | // Connect the sensor to the network and set the node's identity 25 | // 26 | // @return {Promise} Node 27 | // 28 | prepareToPublish() { 29 | log.info(`${MODULE_NAME}: Connecting sensor to the network`) 30 | 31 | // TODO: tidy this up into an connection-status checker fn 32 | // since it gives the node an identity, I think it is ok to wrap it in 33 | return id() 34 | .then((identity) => { 35 | this.identity = identity 36 | log.info(`${MODULE_NAME}: IPFS daemon is running with ID: ${identity.ID}`) 37 | return this 38 | }) 39 | .catch(passOrDie(MODULE_NAME)) 40 | } 41 | 42 | // Publish data to the network 43 | // 44 | // @param {Object} data which should be JSON.stringify-able 45 | // 46 | // @return {Promise} Node 47 | // 48 | publish(data) { 49 | let dataString = data 50 | if (typeof data !== 'string') { 51 | dataString = JSON.stringify(data) 52 | } 53 | log.info(`${MODULE_NAME}: Publishing new data`) 54 | return publish(dataString, this) 55 | .catch(passOrDie(MODULE_NAME)) 56 | } 57 | 58 | // Publish the node's data root to the network 59 | // 60 | // @param {Object} data which should be JSON.stringify-able 61 | // 62 | // @return {Promise} Node 63 | // 64 | publishRoot(data) { 65 | let dataString = data 66 | if (typeof data !== 'string') { 67 | dataString = JSON.stringify(data) 68 | } 69 | log.info(`${MODULE_NAME}: Publishing new root`) 70 | return publishRoot(dataString, this) 71 | .catch(passOrDie(MODULE_NAME)) 72 | } 73 | 74 | // Add new subscription(s) to the node, attach an event handler 75 | // and start polling for each new subscription 76 | // 77 | // @param {Array || String} nodeIds ([peerId, peerId, ...] or peerId) 78 | // @param {Func} cb 79 | // 80 | subscribe(nodeIds, cb) { 81 | if (typeof cb !== 'function') { 82 | throw new NomadError('Callback must be a function') 83 | } 84 | 85 | let newSubscriptions = nodeIds 86 | if (typeof newSubscriptions === 'string') { 87 | newSubscriptions = [newSubscriptions] 88 | } 89 | // TODO: More sanity checking (e.g. for b58 strings) 90 | 91 | let subscriptions = Object.assign({}, this.subscriptions) 92 | 93 | R.forEach((subId) => { 94 | if (!R.has(subId, subscriptions)) { 95 | log.info(`${MODULE_NAME}: ${subId}: Subscribed`) 96 | const newSub = new Subscription(subId) 97 | newSub.addHandler(cb) 98 | newSub.start() 99 | subscriptions[subId] = newSub 100 | } 101 | }, newSubscriptions) 102 | 103 | // Update the node's subscriptions 104 | this.subscriptions = subscriptions 105 | } 106 | 107 | // Remove a subscription from the node 108 | // 109 | // @param {String} subscriptionId (peerId) 110 | // 111 | unsubscribe(nodeId) { 112 | if (typeof nodeId !== 'string') { 113 | throw new NomadError('nodeId must be a string') 114 | } 115 | 116 | let subscriptions = Object.assign({}, this.subscriptions) 117 | if (R.has(nodeId, subscriptions)) { 118 | delete subscriptions[nodeId] 119 | log.info(`${MODULE_NAME}: ${nodeId}: Unsubscribed`) 120 | } 121 | 122 | // Update the node's subscriptions 123 | this.subscriptions = subscriptions 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/publish.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const R = require('ramda') 3 | 4 | const log = require('./utils/log') 5 | const config = require('./utils/config') 6 | const ipfsUtils = require('./utils/ipfs') 7 | const { setHead } = require('./node-cache') 8 | 9 | const MODULE_NAME = 'PUBLISH' 10 | 11 | // Initialize a new sensor head object. This is a blank IPFS DAG object. 12 | // 13 | // @param {Anything} data 14 | // 15 | // @return {Promise} blank ipfs DAG object 16 | // 17 | const initLatestNodeHead = (data) => { 18 | log.info(`${MODULE_NAME}: Initializing a new sensor head object`) 19 | 20 | return Promise.all([ipfsUtils.object.create(), ipfsUtils.data.add(data)]) 21 | .then((results) => { 22 | const emptyDAG = R.head(results) // target 23 | const dataDAG = R.head(R.last(results)) // source 24 | const linkName = 'data' 25 | 26 | log.info(`${MODULE_NAME}: Adding data link to new sensor head`) 27 | return ipfsUtils.object.link(dataDAG.node, emptyDAG, linkName) 28 | }) 29 | } 30 | 31 | // Link the previous sensor head to the new sensor head by creating the 32 | // appropriate links in the ipfs DAG object 33 | // 34 | // @param {Object} sourceDAG (ipfs DAG object) 35 | // @param {Object} targetDAG (ipfs DAG object) 36 | // 37 | // @return {Promise} source -> target linked ipfs DAG object 38 | // 39 | const linkLatestNodeHeadToPrev = (sourceDAG, targetDAG) => { 40 | const linkName = 'prev' 41 | log.info(`${MODULE_NAME}: Adding prev link to new sensor head`) 42 | return ipfsUtils.object.link(sourceDAG, targetDAG, linkName) 43 | } 44 | 45 | // Publish the nodes latest head to the network 46 | // 47 | // @param {Object} dag (ipfs DAG object) 48 | // @param {Object} node (nomad node object) 49 | // 50 | // @return {Promise} nomad node object 51 | // 52 | const publishLatestNodeHead = (dag, node) => { 53 | log.info(`${MODULE_NAME}: Publishing new sensor head (${dag.toJSON().Hash}) with links`, dag.toJSON().Links) 54 | let newHead 55 | 56 | return ipfsUtils.object.put(dag) 57 | .then((headDAG) => { 58 | newHead = headDAG 59 | return ipfsUtils.name.publish(headDAG) 60 | }) 61 | .then(() => { 62 | // write the new head to cache 63 | setHead(newHead) 64 | // Once written to disk, set the new head on the node 65 | node.head = newHead 66 | // return the full node 67 | return node 68 | }) 69 | } 70 | 71 | // Publish the node's first ever node head in the network 72 | // Note: this will not have a 'prev' link in the DAG object! 73 | // 74 | // @param {Anything} data 75 | // @param {Object} node (nomad node object) 76 | // 77 | // @return {Promise} ipfs DAG object 78 | // 79 | const publishNodeRoot = (data, node) => { 80 | log.info(`${MODULE_NAME}: Publishing sensor root`) 81 | 82 | return initLatestNodeHead(data) 83 | .then(newDAG => publishLatestNodeHead(newDAG, node)) 84 | .catch(error => Promise.reject({ PUBLISH_ROOT_ERROR: error })) 85 | } 86 | 87 | // Publish the latest sensor data to the network 88 | // 89 | // @param {Anything} data 90 | // @param {Object} node (nomad node object) 91 | // 92 | // @return {Promise} ipfs DAG object 93 | // 94 | const publishNodeData = (data, node) => { 95 | log.info(`${MODULE_NAME}: Publishing sensor data`) 96 | 97 | if (!ipfsUtils.validDAGNode(node.head)) { 98 | node.head = ipfsUtils.createDAGNode(node.head) 99 | } 100 | 101 | return initLatestNodeHead(data) 102 | .then(newDAG => linkLatestNodeHeadToPrev(node.head, newDAG)) 103 | .then(newDAG => publishLatestNodeHead(newDAG, node)) 104 | } 105 | 106 | // API 107 | // 108 | module.exports = { 109 | publish: publishNodeData, 110 | publishRoot: publishNodeRoot, 111 | } 112 | -------------------------------------------------------------------------------- /src/subscription-cache.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | 3 | const config = require('./utils/config') 4 | const log = require('./utils/log') 5 | const { NomadError } = require('./utils/errors') 6 | 7 | const MODULE_NAME = 'SUBSCRIPTION_CACHE' 8 | 9 | const CACHED_SUB_HEADS_PATH = config.path.cachedSubscriptionHeads 10 | 11 | const initSubscriptionCache = () => { 12 | fs.writeFileSync(CACHED_SUB_HEADS_PATH, `${JSON.stringify({})}\r\n`) 13 | return fs.readFileSync(CACHED_SUB_HEADS_PATH) 14 | } 15 | 16 | // Get a link to the subscription link cache 17 | // 18 | // @param {String} id 19 | // 20 | // @return {String || null} 21 | // 22 | const get = (id) => { 23 | let buffer 24 | let links 25 | let subLink 26 | 27 | // If the file doesn't exist, create it 28 | try { 29 | buffer = fs.readFileSync(CACHED_SUB_HEADS_PATH) 30 | } catch (err) { 31 | buffer = initSubscriptionCache() 32 | } 33 | 34 | // ensure valid json 35 | try { 36 | links = JSON.parse(buffer.toString()) 37 | subLink = links[id] 38 | } catch (err) { 39 | subLink = null 40 | } 41 | 42 | return subLink 43 | } 44 | 45 | // Add a link to the subscription link cache 46 | // 47 | // @param {String} id 48 | // @param {String} link 49 | // 50 | const set = (id, link) => { 51 | log.info(`${MODULE_NAME}: ${id}: Set link ${link}`) 52 | 53 | let links 54 | // TODO: handle if the file is missing somehow 55 | const buffer = fs.readFileSync(CACHED_SUB_HEADS_PATH) 56 | 57 | try { 58 | links = JSON.parse(buffer.toString()) 59 | } catch (err) { 60 | links = {} 61 | } 62 | 63 | links[id] = link 64 | fs.writeFileSync(CACHED_SUB_HEADS_PATH, `${JSON.stringify(links)}\r\n`) 65 | 66 | return link 67 | } 68 | 69 | module.exports = { 70 | get, 71 | set, 72 | } 73 | -------------------------------------------------------------------------------- /src/subscription.js: -------------------------------------------------------------------------------- 1 | const R = require('ramda') 2 | 3 | const log = require('./utils/log') 4 | const ipfsUtils = require('./utils/ipfs') 5 | const { passOrDie } = require('./utils/errors') 6 | const messageCache = require('./message-cache') 7 | const subscriptionCache = require('./subscription-cache') 8 | 9 | const MODULE_NAME = 'SUBSCRIPTION' 10 | 11 | // How often to poll for new subscription messages 12 | // TODO: maybe move this into subscription.js 13 | const POLL_MILLIS = 1000 * 60 14 | 15 | // Class: Subscription 16 | // 17 | module.exports = class Subscription { 18 | constructor(subscriptionId) { 19 | this.id = subscriptionId 20 | // user-supplied handlers to call when new data arrives 21 | this._handlers = [] 22 | // a backlog to handle message delivery if the local subscription head 23 | // gets out of sync from the network's subscription head 24 | this._backlog = [] 25 | // TODO: decide how to use the .head property on a subscription 26 | // it is useful, but figure out the best way 27 | } 28 | 29 | addHandler(handler) { 30 | this._handlers.push(handler) 31 | return true 32 | } 33 | 34 | start() { 35 | // start polling 36 | this._poll() 37 | } 38 | 39 | _poll() { 40 | log.info(`${MODULE_NAME}: ${this.id}: Starting poll`) 41 | 42 | ipfsUtils.id() // TODO: better online check 43 | .then(() => this._getHead()) 44 | .then((head) => this._syncHead(head)) 45 | .then(() => { 46 | log.info(`${MODULE_NAME}: ${this.id}: ${POLL_MILLIS / 1000} seconds until next poll`) 47 | setTimeout(() => this._poll(), POLL_MILLIS) 48 | }) 49 | .catch(passOrDie(MODULE_NAME)) 50 | .catch((err) => { 51 | // TODO: Unify error propagation up the top polling function 52 | // decide how to pass Nomad Errors from within 53 | log.err(`${MODULE_NAME}: ${this.id}: _poll error`, err) 54 | setTimeout(() => this._poll(), POLL_MILLIS) 55 | }) 56 | } 57 | 58 | // Get the head object from the network for a single subscription 59 | // 60 | // @param {String} id (b58 ipfs object hash) 61 | // 62 | // @return {Promise} b58 hash 63 | // 64 | _getHead() { 65 | log.info(`${MODULE_NAME}: ${this.id}: Fetching remote head`) 66 | 67 | return ipfsUtils.name.resolve(this.id) 68 | .then(subscriptionObj => R.prop('Path', subscriptionObj)) 69 | .then((objectHash) => { 70 | const head = ipfsUtils.extractMultihashFromPath(objectHash) 71 | return Promise.resolve(head) 72 | }) 73 | .catch((err) => { 74 | log.err(`${MODULE_NAME}: ${this.id}: _getHead error `, err.message) 75 | return Promise.reject(err.message) 76 | }) 77 | } 78 | 79 | // Sync the network's subscription head with the local subscription index 80 | // 81 | // @param {String} head 82 | // 83 | // @return {Promise} b58 hash 84 | // 85 | _syncHead(head) { 86 | log.info(`${MODULE_NAME}: ${this.id}: Syncing cached head with remote head`) 87 | 88 | // TODO: this is currently SLOW (it is a file read) 89 | // It is a stopgap until we decide how to handle caching 90 | const cachedHead = subscriptionCache.get(this.id) 91 | 92 | // if there is no cached subscription head, cache it and 93 | // deliver the message from the current head 94 | if (R.isNil(cachedHead)) { 95 | log.info(`${MODULE_NAME}: ${this.id}: No cached head found`) 96 | return this._deliverMessage(head) 97 | } 98 | 99 | // if the cached subscription head matches the remote head, 100 | // deliver the message from the current head 101 | if (cachedHead === head) { 102 | log.info(`${MODULE_NAME}: ${this.id}: cached head ${cachedHead} matches remote head ${head}`) 103 | return this._deliverMessage(head) 104 | } 105 | 106 | // if the cached subscription head does NOT match the remote head, 107 | // walk back to find the head that matches the cached head, 108 | // then deliver the message backlog in order 109 | log.info(`${MODULE_NAME}: ${this.id}: cached head ${cachedHead} does not match remote head ${head}`) 110 | return this._walkBack(head) 111 | } 112 | 113 | // Deliver the message (data) for a given head object hash 114 | // 115 | // @param {String} head 116 | // 117 | // @return {Promise} 118 | // 119 | _deliverMessage(head) { 120 | let messageLink 121 | 122 | // TODO: tighten up the logic around message fetch and delivery 123 | // to avoid unnecessary network calls, this can be improved 124 | return ipfsUtils.extractLinkFromIpfsObject(head) 125 | .then((link) => { 126 | messageLink = link 127 | return ipfsUtils.object.cat(link) 128 | }) 129 | .then((message) => { 130 | const cachedHead = subscriptionCache.get(this.id) 131 | 132 | if (head === cachedHead) { 133 | log.info(`${MODULE_NAME}: ${this.id}: Remote head unchanged`) 134 | return true 135 | } 136 | log.info(`${MODULE_NAME}: ${this.id}: Delivering new message ${head}`) 137 | 138 | const result = { id: this.id, link: messageLink, message } 139 | 140 | try { 141 | log.info(`${MODULE_NAME}: ${this.id}: Calling handler`) 142 | 143 | // Call the user-supplied callbacks for the new message 144 | R.forEach(handler => handler(result), this._handlers) 145 | // Add the subscription head link as the cached head 146 | subscriptionCache.set(this.id, head) 147 | 148 | // Add the new message to the store 149 | messageCache.put(this.id, message, messageLink) 150 | 151 | log.info(`${MODULE_NAME}: ${this.id}: Handler successfully called`) 152 | } catch (err) { 153 | log.err(`${MODULE_NAME}: ${this.id}: Error calling handler`, err) 154 | } 155 | 156 | return true 157 | }) 158 | .catch((err) => { 159 | log.err(`${MODULE_NAME}: ${this.id}: _deliverMessage error`, err) 160 | return Promise.reject(err) 161 | }) 162 | } 163 | 164 | _walkBack(link, cached) { 165 | log.info(`${MODULE_NAME}: ${this.id}: Walking back`) 166 | 167 | const cachedHead = cached || subscriptionCache.get(this.id) 168 | 169 | this._backlog.unshift(link) 170 | 171 | // TODO: extractLink should not throw so we know it is a root, 172 | // it should return something that we can work with 173 | return ipfsUtils.extractLinkFromIpfsObject(link, 'prev') 174 | // If we have a 'prev' link 175 | // we are not at the subscription's root 176 | .then((prevLink) => { 177 | // If the 'prev' link does not match the cached head, continue walking back 178 | if (cachedHead !== prevLink) { 179 | log.info(`${MODULE_NAME}: ${this.id}: Cached head and remote head do not match`) 180 | return this._walkBack(prevLink, cachedHead) 181 | } 182 | 183 | // If the 'prev' link matches the cached head, it means that we've arrived at 184 | // the next message that we need to deliver. Start delivering the backlog! 185 | log.info(`${MODULE_NAME}: ${this.id}: Found remote ${prevLink} that points to cached head`) 186 | return this._deliverBacklog() 187 | }) 188 | // If there is an error, the object did not have a 'prev' link. 189 | // We can assume that the current object is the subscription's root 190 | // and that we should now deliver messages, starting with the new root, 191 | // and reset the subscription's cached head to this new root. 192 | // 193 | // Nutshell: A break in the publication chain happened; we now have a new root 194 | .catch((err) => { 195 | if (cachedHead) { 196 | log.info(`${MODULE_NAME}: ${this.id}: Discovered new root, starting delivery`) 197 | } else { 198 | log.info(`${MODULE_NAME}: ${this.id}: Arrived at root, starting delivery`) 199 | } 200 | return this._deliverBacklog() 201 | }) 202 | } 203 | 204 | _deliverBacklog() { 205 | if (!this._backlog.length) { 206 | log.info(`${MODULE_NAME}: ${this.id}: Finished delivering the backlog`) 207 | return Promise.resolve(true) 208 | } 209 | 210 | const linkToDeliver = R.head(this._backlog.splice(0, 1)) 211 | log.info(`${MODULE_NAME}: ${this.id}: Delivering the backlog link ${linkToDeliver}`) 212 | 213 | return this._deliverMessage(linkToDeliver) 214 | .then(() => this._deliverBacklog()) 215 | .catch((err) => { 216 | log.err(`${MODULE_NAME}: ${this.id}: _deliverBacklog error`, err) 217 | return Promise.reject(err) 218 | }) 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /src/utils/config.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const R = require('ramda') 4 | 5 | const NomadError = require('./errors') 6 | 7 | const userConfigPath = path.resolve(__dirname, './../../nomad.json') 8 | const cachedNodeHeadPath = path.resolve(__dirname, './../../cache/node-head.json') 9 | const cachedSubscriptionHeadsPath = path.resolve(__dirname, './../../cache/subscription-heads.json') 10 | 11 | module.exports = { 12 | path: { 13 | cachedNodeHead: cachedNodeHeadPath, 14 | cachedSubscriptionHeads: cachedSubscriptionHeadsPath 15 | }, 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/errors.js: -------------------------------------------------------------------------------- 1 | const R = require('ramda') 2 | 3 | const log = require('./log') 4 | 5 | // Class: Nomad error 6 | // 7 | class NomadError extends Error { 8 | constructor(message) { 9 | super() 10 | Error.captureStackTrace(this, this.constructor) 11 | this.message = `${message}` 12 | } 13 | 14 | toErrorString() { 15 | return `${this.constructor.name}: ${this.message}\n${this.stack}` 16 | } 17 | } 18 | 19 | // Class: Daemon offline error 20 | // 21 | class IPFSErrorDaemonOffline extends NomadError { 22 | constructor() { 23 | super('IPFS daemon offline') 24 | } 25 | } 26 | 27 | // Errors considered fatal; these are used to determine if the error 28 | // should kill the process 29 | const fatalErrors = [IPFSErrorDaemonOffline] 30 | 31 | // Determine if an error is an instance of, what we've determined 32 | // to be, fatal errors 33 | // 34 | // @param {Object} err 35 | // 36 | // @return {Bool} 37 | // 38 | const instanceOfFatalErrors = (err) => { 39 | const matchedErrors = R.find(errorClass => err instanceof errorClass, fatalErrors) 40 | return !R.isNil(matchedErrors) 41 | } 42 | 43 | // Handle 'fatal' errors or pass them along 44 | // 45 | // @param {Object} err 46 | // 47 | // @return {Promise} nomad error object 48 | // 49 | const passOrDie = (moduleName) => { 50 | return (err) => { 51 | if (err instanceof NomadError) { 52 | log.err(`${moduleName}: ${err.toErrorString()}`) 53 | } else { 54 | log.err(`${moduleName}: ${err}`) 55 | } 56 | 57 | if (instanceOfFatalErrors(err)) { 58 | log.err(`${moduleName}: fatal error`) 59 | log.err(`${moduleName}: exiting`) 60 | process.exit(1) 61 | } 62 | return Promise.reject(err) 63 | } 64 | } 65 | 66 | module.exports = { 67 | NomadError, 68 | IPFSErrorDaemonOffline, 69 | passOrDie, 70 | } 71 | -------------------------------------------------------------------------------- /src/utils/ipfs.js: -------------------------------------------------------------------------------- 1 | const bs58 = require('bs58') 2 | const ipfsApi = require('ipfs-api') 3 | const R = require('ramda') 4 | const streamToPromise = require('stream-to-promise') 5 | const { DAGLink, DAGNode } = require('ipfs-merkle-dag') 6 | 7 | const log = require('./log') 8 | const { NomadError, IPFSErrorDaemonOffline } = require('./errors') 9 | 10 | const ipfs = ipfsApi() 11 | 12 | const MODULE_NAME = 'IPFS' 13 | 14 | // General Utils 15 | const bufferFromBase58 = str => new Buffer(bs58.decode(str)) 16 | const base58FromBuffer = bs58.encode 17 | 18 | const extractMultihashFromPath = path => R.replace('/ipfs/', '', path) 19 | 20 | const IPFSConnectionRefusedErrorCode = 'ECONNREFUSED' 21 | 22 | // replaces or passes through certain errors 23 | const mapError = (err) => { 24 | let newError 25 | 26 | switch (err.code) { 27 | case IPFSConnectionRefusedErrorCode: 28 | newError = new IPFSErrorDaemonOffline() 29 | log.err(`${MODULE_NAME}: ${newError.toErrorString()}`) 30 | return Promise.reject(newError) 31 | default: 32 | log.err(`${MODULE_NAME}: Unhandled IPFS error: ${err.message}`) 33 | return Promise.reject(err) 34 | } 35 | } 36 | 37 | // Create a valid DAGNode from an object 38 | // 39 | // @param {Object} obj 40 | // 41 | // @return {Object} DAGNode 42 | // 43 | const createDAGNode = obj => { 44 | // TODO: sanity check object properties!! 45 | // obj is a stringified DAGNode object, not the class instance yet, 46 | // but this explains the capitalization for property access 47 | return new DAGNode(obj.Data, obj.Links) 48 | } 49 | 50 | // Check if an object is a valid DAGNode 51 | // 52 | // @param {Object} obj 53 | // 54 | // @return {Bool} 55 | // 56 | const validDAGNode = (obj) => { 57 | if (obj instanceof DAGNode) { 58 | return true 59 | } 60 | return false 61 | } 62 | 63 | // ID Utils 64 | const id = () => { 65 | // log.info(`${MODULE_NAME}: Checking connection to network`) 66 | return ipfs.id().catch(mapError) 67 | } 68 | 69 | // Data Utils 70 | const data = { 71 | add: (value) => { 72 | // log.info(`${MODULE_NAME}: Getting a hash for newly added data`) 73 | return ipfs.add(new Buffer(value, 'utf8')).catch(mapError) 74 | }, 75 | } 76 | 77 | // Name Utils 78 | const name = { 79 | resolve: (hash) => { 80 | // log.info(`${MODULE_NAME}: Resolving hash ${hash}`) 81 | return ipfs.name.resolve(hash).catch(mapError) 82 | }, 83 | 84 | publish: (dag) => { 85 | const hash = dag.toJSON().Hash 86 | // log.info(`${MODULE_NAME}: Publishing ${hash} via IPNS`) 87 | return ipfs.name.publish(hash) 88 | .then((res) => { 89 | // log.info(`${MODULE_NAME}: Successfully published via IPNS`) 90 | return Promise.resolve(res) 91 | }) 92 | .catch(mapError) 93 | }, 94 | } 95 | 96 | // Object Utils 97 | const object = { 98 | // Currently expect lookup to be a DAG path...generify this 99 | // TODO: abstract! 100 | get: (lookup) => { 101 | // log.info(`${MODULE_NAME}: Getting object ${lookup}`) 102 | return ipfs.object.get(bufferFromBase58(extractMultihashFromPath(lookup))).catch(mapError) 103 | }, 104 | 105 | // Currently expect lookup to be a DAG path...generify this 106 | // TODO: abstract! 107 | data: (lookup) => { 108 | // log.info(`${MODULE_NAME}: Getting object data for ${lookup}`) 109 | return ipfs.object.data(bufferFromBase58(extractMultihashFromPath(lookup))).catch(mapError) 110 | }, 111 | 112 | put: (dag) => { 113 | // log.info(`${MODULE_NAME}: Putting a DAG object`) 114 | return ipfs.object.put(dag).catch(mapError) 115 | }, 116 | 117 | create: () => { 118 | // log.info(`${MODULE_NAME}: Creating a new DAG object`) 119 | return ipfs.object.new().catch(mapError) 120 | }, 121 | 122 | link: (sourceDAG, targetDAG, linkName) => { 123 | // log.info(`${MODULE_NAME}: Adding '${linkName}' link to an object`) 124 | 125 | if (R.isNil(sourceDAG)) { 126 | return Promise.reject(new NomadError('MODULE_NAME: sourceDAG was null')) 127 | } 128 | 129 | if (R.isNil(targetDAG)) { 130 | return Promise.reject(new NomadError('MODULE_NAME: targetDAG was null')) 131 | } 132 | 133 | const sourceHash = sourceDAG.toJSON().Hash 134 | const targetHash = targetDAG.toJSON().Hash 135 | 136 | const sourceDataSize = sourceDAG.data ? sourceDAG.data.Size : 0 137 | const newLink = new DAGLink( 138 | linkName, 139 | sourceDataSize, 140 | bufferFromBase58(sourceHash) 141 | ) 142 | 143 | return ipfs.object.patch.addLink(bufferFromBase58(targetHash), newLink).catch(mapError) 144 | }, 145 | 146 | // Currently expect DAG hash 147 | cat: (lookup) => { 148 | // log.info(`${MODULE_NAME}: Cat-ing ${lookup}`) 149 | return ipfs.cat(lookup) 150 | .then(readStream => streamToPromise(readStream)) 151 | .then(buffer => buffer.toString()) 152 | .catch(mapError) 153 | }, 154 | } 155 | 156 | // Extract a named link from a specified object (data || prev) 157 | // 158 | // @param {String} hash (b58 ipfs object hash) 159 | // @param {String} linkName (optional) 160 | // 161 | // @return {Promise} 162 | // 163 | const extractLinkFromIpfsObject = (hash, linkName = 'data') => { 164 | // log.info(`${MODULE_NAME}: fetching data for object ${hash}`) 165 | 166 | return object.get(hash) 167 | .then((ipfsObj) => { 168 | const links = ipfsObj.links 169 | if (R.isNil(links)) { 170 | // log.info(`${MODULE_NAME}: Object is missing a links property`) 171 | throw new NomadError('Object is missing links property') 172 | } 173 | 174 | const linkData = R.find(R.propEq('name', linkName), links) 175 | if (R.isNil(linkData)) { 176 | // log.info(`${MODULE_NAME}: Object is missing a ${linkName} link`) 177 | throw new NomadError(`Object is missing a ${linkName} link`) 178 | } 179 | 180 | const encoded = base58FromBuffer(linkData.hash) 181 | return Promise.resolve(encoded) 182 | }) 183 | } 184 | 185 | module.exports = { 186 | id, 187 | data, 188 | name, 189 | object, 190 | base58FromBuffer, 191 | extractMultihashFromPath, 192 | extractLinkFromIpfsObject, 193 | validDAGNode, 194 | createDAGNode, 195 | } 196 | -------------------------------------------------------------------------------- /src/utils/log.js: -------------------------------------------------------------------------------- 1 | const debug = require('debug') 2 | 3 | // TODO: have both nomad and ipfs loggers so that we 4 | // can turn off the ipfs loggers in production 5 | const log = debug('nomad') 6 | log.warn = debug('nomad:warn') 7 | log.err = debug('nomad:error') 8 | log.info = debug('nomad:info') 9 | log.verbose = debug('nomad:verbose') 10 | log.debug = debug('nomad:debug') 11 | 12 | module.exports = log 13 | -------------------------------------------------------------------------------- /test/blackbox.spec.js: -------------------------------------------------------------------------------- 1 | const expect = require('chai').expect 2 | const R = require('ramda') 3 | 4 | const ipfsControl = require('./utils/ipfs-control') 5 | const Node = require('./../src/node') 6 | const log = require('./../src/utils/log') 7 | 8 | let node = null 9 | let peerId = null 10 | const IPFSLaunchWaitSeconds = 50 11 | 12 | function getPeerId(configObj) { 13 | return configObj.Identity.PeerID 14 | } 15 | 16 | describe('Black box test of publish then subscribe:', () => { 17 | before((done) => { 18 | ipfsControl.cleanIPFS() 19 | ipfsControl.initIPFS() 20 | ipfsControl.startIPFSDaemon() 21 | log.info(`waiting ${IPFSLaunchWaitSeconds} seconds for IPFS daemon to start and find peers`) 22 | setTimeout(() => { 23 | peerId = getPeerId(ipfsControl.getConfig()) 24 | node = new Node() // subscribe to self 25 | done() 26 | }, IPFSLaunchWaitSeconds * 1000) 27 | }) 28 | 29 | after(() => { 30 | ipfsControl.stopIPFSDaemon() 31 | ipfsControl.cleanIPFS() 32 | }) 33 | 34 | describe('publish: ', () => { 35 | // it('should throw when publishing a message on a new IPFS instance before publishing root', (done) => { 36 | // node.publish('hello').catch((err) => { 37 | // expect(err).to.exist 38 | // done() 39 | // }) 40 | // }) 41 | 42 | it('should succeed when preparing to publish', (done) => { 43 | node.prepareToPublish().then(() => { done() }) 44 | }) 45 | 46 | it('should succeed when publishing a string root message', (done) => { 47 | node.publishRoot('root message').then(() => { done() }) 48 | }) 49 | 50 | it('should succeed when publishing a string message after publishing a root message', (done) => { 51 | node.publish('second message').then(() => { done() }) 52 | }) 53 | 54 | it('should succeed when publishing a javascript object message', (done) => { 55 | node.publish({message: 'message as object'}).then(() => { done() }) 56 | }) 57 | }) 58 | 59 | describe('subscribe: ', () => { 60 | it('should attach a handler and receive the latest message pointed to by IPNS when theres no cached subscription head', (done) => { 61 | node.subscribe([peerId], (message) => { 62 | expect(message).to.exist 63 | expect(R.keys(node.subscriptions).length).to.eql(1) 64 | done() 65 | }) 66 | }) 67 | }) 68 | 69 | describe('unsubscribe: ', () => { 70 | it('should handle invalid args', () => { 71 | const thrower = () => node.unsubscribe({ something: 123 }) 72 | expect(thrower).to.throw 73 | }) 74 | 75 | it('success', () => { 76 | node.unsubscribe(peerId) 77 | expect(R.keys(node.subscriptions).length).to.eql(0) 78 | }) 79 | }) 80 | }) 81 | -------------------------------------------------------------------------------- /test/index.spec.js: -------------------------------------------------------------------------------- 1 | const R = require('ramda') 2 | const expect = require('chai').expect 3 | 4 | const Exported = require('./../src') 5 | const Node = require('./../src/node') 6 | 7 | describe('Index:', () => { 8 | describe('exports a valid Node constructor:', () => { 9 | it('success', () => { 10 | expect(R.isNil(Exported)).to.eql(false) 11 | expect(typeof Exported).to.eql('function') 12 | const node = new Exported() 13 | expect(node instanceof Node).to.eql(true) 14 | }) 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /test/node.spec.js: -------------------------------------------------------------------------------- 1 | // // const R = require('ramda') 2 | // const expect = require('chai').expect 3 | 4 | // const Node = require('./../src/node') 5 | 6 | // describe('Node:', () => { 7 | // let node 8 | 9 | // describe('constructor:', () => { 10 | // it('success', () => { 11 | // node = new Node() 12 | // }) 13 | // }) 14 | // }) 15 | -------------------------------------------------------------------------------- /test/publish.spec.js: -------------------------------------------------------------------------------- 1 | const expect = require('chai').expect 2 | 3 | describe('Publish:', () => { 4 | before(() => {}) 5 | after(() => {}) 6 | 7 | describe('something:', () => { 8 | it('food', () => { 9 | expect(1).to.eql(1) 10 | }) 11 | }) 12 | }) 13 | -------------------------------------------------------------------------------- /test/subscription.spec.js: -------------------------------------------------------------------------------- 1 | const expect = require('chai').expect 2 | 3 | const Subscription = require('./../src/subscription') 4 | 5 | describe('Subscription:', () => { 6 | const handlerA = () => {} 7 | let removeHandlerA 8 | 9 | let subscription 10 | let subscriptionId = 'someId' 11 | 12 | before(() => {}) 13 | after(() => {}) 14 | 15 | describe('constructor:', () => { 16 | it('success', () => { 17 | subscription = new Subscription(subscriptionId) 18 | expect(subscription).to.exist 19 | }) 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /test/utils/ipfs-control.js: -------------------------------------------------------------------------------- 1 | const child = require('child_process') 2 | const path = require('path') 3 | const log = require('./../../src/utils/log') 4 | 5 | const ipfs = 'ipfs' 6 | const repoPath = `${path.resolve(__dirname)}/.ipfs-nomad-test` 7 | 8 | let ipfsDaemonHandle = null 9 | 10 | function execAndLog(command, options) { 11 | log.info(child.execSync(command, options).toString()) 12 | } 13 | 14 | function initIPFS() { 15 | log.info(`Creating test IPFS repo at ${repoPath}`) 16 | execAndLog(`${ipfs} init`, { env: { IPFS_PATH: repoPath } }) 17 | } 18 | 19 | function cleanIPFS() { 20 | execAndLog(`rm -rf ${repoPath}`) 21 | } 22 | 23 | function startIPFSDaemon() { 24 | ipfsDaemonHandle = child.exec(`${ipfs} daemon`, { env: { IPFS_PATH: repoPath } }) 25 | } 26 | 27 | function stopIPFSDaemon() { 28 | ipfsDaemonHandle.kill() 29 | ipfsDaemonHandle = null 30 | } 31 | 32 | function getConfig() { 33 | return JSON.parse(child.execSync('ipfs config show', { env: { IPFS_PATH: repoPath } }).toString()) 34 | } 35 | 36 | module.exports = { 37 | initIPFS, 38 | cleanIPFS, 39 | startIPFSDaemon, 40 | stopIPFSDaemon, 41 | getConfig, 42 | } 43 | --------------------------------------------------------------------------------