github.com/simpleiot/simpleiot@v0.18.3/frontend/lib/siot-nats.mjs (about)

     1  import { connect as natsConnect, StringCodec } from "nats.ws"
     2  
     3  // The syntax: `import { Timestamp } from ...` will not work properly
     4  // in Node.js because of how protobuf generates the CommonJS code, so we
     5  // have to do a little more work...
     6  import timestamp_pb from "google-protobuf/google/protobuf/timestamp_pb.js"
     7  const { Timestamp } = timestamp_pb
     8  import point_pb from "./protobuf/point_pb.js"
     9  const { Points, Point } = point_pb
    10  import node_pb from "./protobuf/node_pb.js"
    11  const { NodesRequest } = node_pb
    12  import message_pb from "./protobuf/message_pb.js"
    13  const { Message } = message_pb
    14  import notification_pb from "./protobuf/notification_pb.js"
    15  const { Notification } = notification_pb
    16  
    17  // eslint-disable-next-line new-cap
    18  const strCodec = StringCodec()
    19  
    20  // connect opens and returns a connection to SIOT / NATS via WebSockets
    21  export * from "nats.ws"
    22  export async function connect(opts = {}) {
    23  	const { servers = ["ws://localhost:9222"] } = opts
    24  	const nc = await natsConnect({ ...opts, servers })
    25  
    26  	// Force SIOTConnection to inherit from `nc` prototype
    27  	SIOTConnection.prototype = Object.create(
    28  		Object.getPrototypeOf(nc),
    29  		Object.getOwnPropertyDescriptors(SIOTConnection.prototype)
    30  	)
    31  	// Create new instance of SIOTConnection and then assign `nc` properties
    32  	return Object.assign(new SIOTConnection(), nc)
    33  }
    34  
    35  // SIOTConnection is a wrapper around a NatsConnectionImpl
    36  function SIOTConnection() {
    37  	// do nothing
    38  }
    39  
    40  Object.assign(SIOTConnection.prototype, {
    41  	// getNode sends a request to `nodes.<parent>.<id>` to retrieve an array of
    42  	// NodeEdges for the specified Node id.
    43  	// - If `id` is "all" or falsy, this calls `getNodeChildren` instead (see
    44  	// below); however, we strongly recommend using `getNodeChildren` directly
    45  	// - If `parent` is "all" or falsy, all instances of the specified node are
    46  	// returned
    47  	// - If `parent` is "root", only root nodes are returned
    48  	// - `opts` are options passed to the NATS request
    49  	async getNode(id, { parent, type, includeDel, opts } = {}) {
    50  		if (id === "all" || id === "*" || !id) {
    51  			return this.getNodeChildren(parent, { type, includeDel, opts })
    52  		}
    53  		if (parent === "*") {
    54  			parent = "all"
    55  		}
    56  
    57  		const points = [
    58  			{ type: "nodeType", text: type },
    59  			{ type: "tombstone", value: includeDel ? 1 : 0 },
    60  		]
    61  		const payload = encodePoints(points, this.userID)
    62  		const m = await this.request(
    63  			"nodes." + (parent || "all") + "." + id,
    64  			payload,
    65  			opts
    66  		)
    67  		return decodeNodesRequest(m.data)
    68  	},
    69  
    70  	// getNodeChildren sends a request to `nodes.<parentID>.<id>` to retrieve
    71  	// an array of child NodeEdges of the specified parent node.
    72  	// - If `parent` is "root", all root nodes are retrieved
    73  	// - `type` - can be used to filter nodes of a specified type (defaults to "")
    74  	// - `includeDel` - set to true to include deleted nodes (defaults to false)
    75  	// - `recursive` - set to true to recursively retrieve all descendants matching
    76  	//   the criteria. In this case, each returned NodeEdge will contain a
    77  	//   `children` property, which is an array of that Node's descendant NodeEdges.
    78  	//
    79  	//   Set to "flat" to return a single flattened array of NodeEdges.
    80  	//
    81  	//   Note: If `type` is also set when `recursive` is truthy, `type` still
    82  	//   restricts which nodes are recursively searched. Consider removing the
    83  	//   `type` filter and filter manually.
    84  	// - `opts` are options passed to the NATS request
    85  	async getNodeChildren(
    86  		parentID,
    87  		{ type, includeDel, recursive, opts, _cache = {} } = {}
    88  	) {
    89  		if (
    90  			parentID === "all" ||
    91  			parentID === "*" ||
    92  			parentID === "none" ||
    93  			!parentID
    94  		) {
    95  			throw new Error("parent node ID must be specified")
    96  		}
    97  
    98  		const points = [
    99  			{ type: "nodeType", text: type },
   100  			{ type: "tombstone", value: includeDel ? 1 : 0 },
   101  		]
   102  
   103  		const payload = encodePoints(points, this.userID)
   104  		const m = await this.request("nodes." + parentID + ".all", payload, opts)
   105  		const nodeEdges = decodeNodesRequest(m.data)
   106  		if (!recursive) {
   107  			return nodeEdges
   108  		}
   109  
   110  		const flat = recursive === "flat"
   111  		const nodeChildren = [] // used if flattening
   112  		// Note: recursive calls are done serially to fully utilize
   113  		// the temporary `_cache`
   114  		for (const n of nodeEdges) {
   115  			const children =
   116  				_cache[n.id] ||
   117  				(await this.getNodeChildren(n.id, {
   118  					type,
   119  					includeDel,
   120  					recursive,
   121  					opts,
   122  					_cache,
   123  				}))
   124  			// update cache
   125  			// eslint-disable-next-line require-atomic-updates
   126  			_cache[n.id] = children
   127  			if (flat) {
   128  				nodeChildren.push(...children)
   129  			} else {
   130  				// If not flattening, add `children` key to `n`
   131  				n.children = children
   132  			}
   133  		}
   134  		if (flat) {
   135  			// If flattening, simply return flat array of node edges
   136  			return nodeEdges.concat(nodeChildren)
   137  		}
   138  		return nodeEdges
   139  	},
   140  
   141  	// getNodesForUser returns the parent nodes for the given userID along
   142  	// with their descendants if `recursive` is truthy.
   143  	// - `type` - can be used to filter nodes of a specified type (defaults to "")
   144  	// - `includeDel` - set to true to include deleted nodes (defaults to false)
   145  	// - `recursive` - set to true to recursively retrieve all descendants matching
   146  	//   the criteria. In this case, each returned NodeEdge will contain a
   147  	//   `children` property, which is an array of that Node's descendant NodeEdges.
   148  	//   Set to "flat" to return a single flattened array of NodeEdges.
   149  	// - `opts` are options passed to the NATS request
   150  	async getNodesForUser(userID, { type, includeDel, recursive, opts } = {}) {
   151  		// Get root nodes of the user node
   152  		const rootNodes = await this.getNode(userID, {
   153  			parent: "all",
   154  			includeDel,
   155  			opts,
   156  		})
   157  
   158  		// Create function to filter nodes based on `type` and `includeDel`
   159  		const filterFunc = (n) => {
   160  			const tombstone = n.edgepointsList.find((e) => e.type === "tombstone")
   161  			return (
   162  				(!type || n.type === type) &&
   163  				(includeDel || (tombstone && tombstone.value % 2 === 0))
   164  			)
   165  		}
   166  
   167  		const _cache = {}
   168  		const flat = recursive === "flat"
   169  		const parentNodes = await Promise.all(
   170  			rootNodes.filter(filterFunc).map(async (n) => {
   171  				const [parentNode] = await this.getNode(n.parent, { opts })
   172  				if (!recursive) {
   173  					return parentNode
   174  				}
   175  				const children = await this.getNodeChildren(n.parent, {
   176  					// TODO: Not sure if `type` should be passed here since we need
   177  					// to do recursive search
   178  					type,
   179  					includeDel,
   180  					recursive,
   181  					opts,
   182  					_cache,
   183  				})
   184  				if (flat) {
   185  					return [parentNode].concat(children)
   186  				}
   187  				return Object.assign(parentNode, { children })
   188  			})
   189  		)
   190  		if (flat) {
   191  			return parentNodes.flat()
   192  		}
   193  		return parentNodes
   194  	},
   195  
   196  	// _subscribePointsSubject subscribes to a NATS subject and returns an async
   197  	// iterable for Point objects
   198  	_subscribePointsSubject(subject) {
   199  		const [subjectType] = subject.split(".")
   200  		const sub = this.subscribe(subject)
   201  		// Return subscription wrapped by new async iterator
   202  		return Object.assign(Object.create(sub), {
   203  			async *[Symbol.asyncIterator]() {
   204  				// Iterator reads and decodes array of Points from subscription
   205  				for await (const m of sub) {
   206  					const { pointsList } = Points.deserializeBinary(m.data).toObject()
   207  					if (pointsList.length === 0) {
   208  						// Just abort in the rare case that no points are
   209  						// emitted, but a NATS message was published anyway
   210  						continue
   211  					}
   212  					// Convert `time` to JavaScript date and return each point
   213  					for (const p of pointsList) {
   214  						p.time = new Date(p.time.seconds * 1e3 + p.time.nanos / 1e6)
   215  						if (!p.key) {
   216  							p.key = "0"
   217  						}
   218  					}
   219  					// Send points as a array of points
   220  					if (subjectType === "up") {
   221  						const [, upstreamID, nodeID, parentID] = m.subject.split(".")
   222  						yield {
   223  							upstreamID,
   224  							nodeID,
   225  							parentID, // populated for edge points
   226  							subject: m.subject,
   227  							points: pointsList,
   228  						}
   229  					} else {
   230  						const [, nodeID, parentID] = m.subject.split(".")
   231  						yield {
   232  							nodeID,
   233  							parentID, // populated for edge points
   234  							subject: m.subject,
   235  							points: pointsList,
   236  						}
   237  					}
   238  				}
   239  			},
   240  		})
   241  	},
   242  
   243  	// Subscribes to `p.<nodeID>` and returns an async iterable for an array of
   244  	// Point objects. `nodeID` can be `*` or `all`.
   245  	subscribePoints(nodeID) {
   246  		if (nodeID === "all") {
   247  			nodeID = "*"
   248  		}
   249  		return this._subscribePointsSubject("p." + nodeID)
   250  	},
   251  	// Subscribes to `p.<nodeID>.<parentID>` and returns an async iterable for
   252  	// an array of Point objects. `nodeID` or `parentID` can be `*` or `all`.
   253  	subscribeEdgePoints(nodeID, parentID) {
   254  		if (nodeID === "all") {
   255  			nodeID = "*"
   256  		}
   257  		if (parentID === "all") {
   258  			parentID = "*"
   259  		}
   260  		return this._subscribePointsSubject("p." + nodeID + "." + parentID)
   261  	},
   262  	// Subscribes to `up.<upstreamID>.<nodeID>` and returns an async iterable
   263  	// for an array of Point objects. `nodeID` can be `*` or `all`.
   264  	subscribeUpstreamPoints(upstreamID, nodeID) {
   265  		if (nodeID === "all") {
   266  			nodeID = "*"
   267  		}
   268  		return this._subscribePointsSubject("up." + upstreamID + "." + nodeID)
   269  	},
   270  	// Subscribes to `up.<upstreamID>.<nodeID>.<parentID>` and returns an async
   271  	// iterable for an array of Point objects. `nodeID` or `parentID` can be
   272  	// `*` or `all`.
   273  	subscribeUpstreamEdgePoints(upstreamID, nodeID, parentID) {
   274  		if (nodeID === "all") {
   275  			nodeID = "*"
   276  		}
   277  		if (parentID === "all") {
   278  			parentID = "*"
   279  		}
   280  		return this._subscribePointsSubject(
   281  			"up." + upstreamID + "." + nodeID + "." + parentID
   282  		)
   283  	},
   284  
   285  	// setUserID sets the user ID for this connection; any points / edge points
   286  	// sent from this connection will have their origin set to the specified
   287  	// userID
   288  	setUserID(userID) {
   289  		this.userID = userID
   290  	},
   291  
   292  	// sendNodePoints sends an array of `points` for a given `nodeID`
   293  	// - `ack` - true if function should block waiting for send acknowledgement
   294  	// (defaults to true)
   295  	// - `opts` are options passed to the NATS request
   296  	async sendNodePoints(nodeID, points, { ack = true, opts } = {}) {
   297  		const payload = encodePoints(points, this.userID)
   298  		if (!ack) {
   299  			await this.publish("p." + nodeID, payload, opts)
   300  			return
   301  		}
   302  
   303  		const m = await this.request("p." + nodeID, payload, opts)
   304  
   305  		// Assume message data is an error message
   306  		if (m.data && m.data.length > 0) {
   307  			throw new Error(
   308  				`error sending points for node '${nodeID}': ` + strCodec.decode(m.data)
   309  			)
   310  		}
   311  	},
   312  
   313  	// sendEdgePoints sends an array of `edgePoints` for the edge between
   314  	// `nodeID` and `parentID`
   315  	// - `ack` - true if function should block waiting for send acknowledgement
   316  	// (defaults to true)
   317  	// - `opts` are options passed to the NATS request
   318  	async sendEdgePoints(
   319  		nodeID,
   320  		parentID,
   321  		edgePoints,
   322  		{ ack = true, opts } = {}
   323  	) {
   324  		const payload = encodePoints(edgePoints, this.userID)
   325  		if (!ack) {
   326  			await this.publish("p." + nodeID + "." + parentID, payload, opts)
   327  			return
   328  		}
   329  
   330  		const m = await this.request("p." + nodeID + "." + parentID, payload, opts)
   331  
   332  		// Assume message data is an error message
   333  		if (m.data && m.data.length > 0) {
   334  			throw new Error(
   335  				`error sending edge points between nodes '${nodeID}' and '${parentID}': ` +
   336  					strCodec.decode(m.data)
   337  			)
   338  		}
   339  	},
   340  
   341  	// subscribeMessages subscribes to `node.<nodeID>.msg` and returns an async
   342  	// iterable for Message objects
   343  	subscribeMessages(nodeID) {
   344  		const sub = this.subscribe("node." + nodeID + ".msg")
   345  		// Return subscription wrapped by new async iterator
   346  		return Object.assign(Object.create(sub), {
   347  			async *[Symbol.asyncIterator]() {
   348  				// Iterator reads and decodes Messages from subscription
   349  				for await (const m of sub) {
   350  					yield Message.deserializeBinary(m.data).toObject()
   351  				}
   352  			},
   353  		})
   354  	},
   355  
   356  	// subscribeNotifications subscribes to `node.<nodeID>.not` and returns an async
   357  	// iterable for Notification objects
   358  	subscribeNotifications(nodeID) {
   359  		const sub = this.subscribe("node." + nodeID + ".not")
   360  		// Return subscription wrapped by new async iterator
   361  		return Object.assign(Object.create(sub), {
   362  			async *[Symbol.asyncIterator]() {
   363  				// Iterator reads and decodes Messages from subscription
   364  				for await (const m of sub) {
   365  					yield Notification.deserializeBinary(m.data).toObject()
   366  				}
   367  			},
   368  		})
   369  	},
   370  })
   371  
   372  // decodeNodesRequest decodes a protobuf-encoded NodesRequest and returns
   373  // the array of nodes returned by the request
   374  function decodeNodesRequest(data) {
   375  	const { nodesList, error } = NodesRequest.deserializeBinary(data).toObject()
   376  	if (error) {
   377  		throw new Error("NodesRequest decode error: " + error)
   378  	}
   379  
   380  	for (const n of nodesList) {
   381  		// Convert `time` to JavaScript date for each point
   382  		for (const p of n.pointsList) {
   383  			p.time = new Date(p.time.seconds * 1e3 + p.time.nanos / 1e6)
   384  		}
   385  		for (const p of n.edgepointsList) {
   386  			p.time = new Date(p.time.seconds * 1e3 + p.time.nanos / 1e6)
   387  		}
   388  	}
   389  	return nodesList
   390  }
   391  
   392  // encodePoints returns protobuf encoded Points
   393  function encodePoints(points, userID) {
   394  	const payload = new Points()
   395  	// Convert `time` from JavaScript date if needed
   396  	points = points.map((p) => {
   397  		if (p instanceof Point) {
   398  			return p
   399  		}
   400  		let { time = new Date() } = p
   401  		const { type, value, text, key, tombstone, data, origin } = p
   402  		p = new Point()
   403  		if (!(time instanceof Timestamp)) {
   404  			let { seconds, nanos } = time
   405  			if (time instanceof Date) {
   406  				const ms = time.valueOf()
   407  				seconds = Math.round(ms / 1e3)
   408  				nanos = (ms % 1e3) * 1e6
   409  			}
   410  			time = new Timestamp()
   411  			time.setSeconds(seconds)
   412  			time.setNanos(nanos)
   413  		}
   414  		p.setType(type)
   415  		if (value || value === 0) {
   416  			p.setValue(value)
   417  		}
   418  		p.setTime(time)
   419  		if (text) {
   420  			p.setText(text)
   421  		}
   422  		p.setKey(key || "0")
   423  		if (tombstone) {
   424  			p.setTombstone(tombstone)
   425  		}
   426  		if (data) {
   427  			p.setData(data)
   428  		}
   429  		if (userID || origin) {
   430  			// Note: Prefer `userID` over point `origin`
   431  			p.setOrigin(userID || origin)
   432  		}
   433  		return p
   434  	})
   435  	payload.setPointsList(points)
   436  	return payload.serializeBinary()
   437  }