A type-safe asynchronous RPC Service facility for connecting your app to the network.
Network-Services provides a simple and intuitive toolkit that makes connecting your app to the network easy. You can use Network-Services to transform your application into a network connected Service App. You can connect to your Service App from the same process or another process and call methods on it using a type-safe Service API. You can optionally use a Service Pool to scale your Service App.
A Network-Services app can be explained with a complete and simple example. In the "Hello, world!" example shown below, a Greeter Service App is hosted on 127.0.0.1:3000 and its greeter.greet method is called over a net.Socket using a Service API of type Greeter.
- Type-safe APIs: code completion, parameter types, and return types.
- Easily scale your Service App using a Service Pool.
- Return values and Errors are marshalled back to the caller.
- Infinite property nesting; you can use a Service API to call nested properties on a Service App at any depth.
- Bi-directional asynchronous RPC over TCP.
- Security can be implemented using the native Node TLS module (i.e., TLS and Client Certificate Authentication).
- A configurable message protocol. You can marshal your messages however you choose (e.g., JSON, binary, etc.), or use the default minimalist JSON message protocol.
- Extend Network-Services using the native
stream.Duplexinterface.
- Installation
- Concepts
- Usage
- Examples
- API
- Type safety
- Extend Network-Services
- Scaling
- Message protocol
- Best practices
- Versioning
- Test
- Support
npm install network-servicesNetwork-Services features an intuitive API that can be most easily understood by looking at an example or common usage. There are four important concepts that comprise the API, a Service, a Service App, a Service API, and a utility Service Pool implementation.
A Service instance coordinates bi-directional communication over a stream.Duplex (e.g., a net.Socket). Once a Service is instantiated it can be used in order to create a Service App or a Service API. You can create a Service using the createService helper function.
A Service App is a user defined object instance (i.e., an instance of your application) that is connected to a Service API over a stream.Duplex (e.g., a net.Socket). You can use the service.createServiceApp<T> helper function, with your app as its argument, in order to create a Service App. Once a Service App is instantiated, its methods can be called using a Service API instantiated in the same or different process.
A Service API is a type-safe representation of your remote Service App. You can create a Service API using the service.createServiceAPI<T> helper function. service.createServiceAPI<T> will return a Proxy that is type cast in order to make the methods that comprise <T> suitable for making asynchronous function calls. You can call methods on the Service API object much how you would call methods on an instance of <T> itself.
A Service Pool is an optional utility feature that facilitates scaling Service Apps using Worker threads. The Service Pool implementation is just one of many scaling models that could be used in order to scale a Network-Services app. You can create a Service Pool using the network-services.createServicePool helper function. Because a pool of Service Apps may be shared by many Service API clients (i.e., a many-to-many relationship), the Service Pool implementation is limited to request-response messaging; a request (i.e., a method call) is made using a Service API and the response (i.e., the return value) from the Service App is returned to the caller. However, a more sophisticated implementation could support coordinated bi-directional communication between many Service API clients and a pool of Service Apps.
Please see the Scalable "Hello, World!" example for a working implementation using a Service Pool.
Using Network-Services involves creating a Service App and calling its methods over a stream (e.g., a net.Socket) using a Service API. In this example you will create a Greeter Service and call its greeter.greet method over a net.Socket using an asynchronous Service API of type Greeter.
import * as net from "node:net";
import { createService } from "network-services";class Greeter {
greet(kind: string) {
return `Hello, ${kind} world!`;
}
}
const greeter = new Greeter();const server = net.createServer().listen({ port: 3000, host: "127.0.0.1" });
server.on("connection", (socket: net.Socket) => {
const service = createService(socket);
service.createServiceApp(greeter); // The greeter.greet method can now be called over the `net.Socket` using a Service API.
});You will use the greeter Service API in order to call the remote Service App's methods and log the greeting.
const socket = net.connect({ port: 3000, host: "127.0.0.1" });
socket.on("ready", async () => {
const service = createService(socket);
const greeter = service.createServiceAPI<Greeter>(); // Create a Service API of type Greeter.
const greeting = await greeter.greet("happy");
// ^
// The `greeter` object facilitates code completion, parameter types and return types.
console.log(greeting); // Hello, happy world!
});Please see the "Hello, World!" example for a working implementation. For a scalable implementation, please see the Scalable "Hello, World!" example.
In the "Hello, World!" example communication is uni-directional (i.e., it supports request-response messaging). However, Network-Services also supports bi-directional communication over the same socket. Please see the Bi-directional Type-safe APIs example for how to implement bi-directional communication.
Please see the Usage section above or the "Hello, World!" example for a working implementation.
Please see the Bi-directional Type-Safe APIs example for a working implementation.
Please see the TLS Encryption and Client Authentication example for a working implementation.
Please see the Nested Method example for a working implementation.
Please see the Scalable "Hello, World!" example for a working implementation.
stream<stream.Duplex>Astream.Duplex(e.g., anet.Socket). Thestream.Duplexwill be used for bi-directional communication between Service Apps and Service APIs.options<ServiceOptions & MuxOptions>ingressQueueSizeLimit<number>An optional ingress buffer size limit in bytes. This argument specifies the limit on buffered data that may accumulate from calls from the remote Service API and return values from the remote Service App. If the size of the ingress buffer exceeds this value, the stream will emit aQueueSizeLimitErrorand close. Default:undefined(i.e., no limit).egressQueueSizeLimit<number>An optional egress buffer size limit in bytes. This argument specifies the limit on buffered data that may accumulate from calls to the remote Service App and return values to the remote Service API. If the size of the egress buffer exceeds this value, aQueueSizeLimitErrorwill be thrown and the stream will close. Default:undefined(i.e., no limit).muxClass<MuxConstructor>An optionalMuximplementation. Messages are muxed as they enter and leave thestream.Duplex. You can use one of the default muxers or provide a custom implementation. For example, you can extend the defaultnetwork-services.BufferMuxand override theserializeMessageanddeserializeMessagemethods in order to implement a custom message protocol (e.g., a binary message protocol). If a customMuximplementation is not provided here, Network-Services will provide a defaultMuximplementation compatible with the underlyingstream.Duplex. Default muxers respect back-pressure. Default: aBufferMuxor anObjectMuxfor streams in object mode.
Returns: <Service>
public service.createServiceApp<T>(app, options)
app<object>An instance of your application.options<ServiceAppOptions<T>>paths<Array<PropPath<Async<T>>>>AnArrayof property paths (i.e., dot-pathstrings). If defined, only property paths in this list may be called on the Service App. Each element of the array is aPropPathand aPropPathis simply a dot-pathstringrepresentation of a property path. Please see the Nested Method example for a working implementation. Default:undefined.
Returns: <ServiceApp<T>>
public service.createServiceAPI<T>(options)
options<ServiceAPIOptions>timeout<number>Optional argument in milliseconds that specifies thetimeoutfor function calls. Default:undefined(i.e., no timeout).identifierGenerator<IdentifierGenerator>An optional instance of a class that implements thenetwork-services.IdentifierGeneratorinterface. This class instance will be used in order to generate a unique identifier for each API call. The defaultnetwork-services.NumericIdentifierGeneratorwill work for the common case; however, a more robust solution may be required for certain custom implementations. Default:network-services.NumericIdentifierGenerator
Returns: <Async<T>> A type cast Proxy object of type <Async<T>> that consists of asynchronous analogues of methods in <T>.
The
service.createServiceAPI<T>helper function returns a JavaScriptProxyobject cast to type<Async<T>>.service.createServiceAPI<T>filters and transforms the function types that comprise<T>into their asynchronous analogues i.e., if a function type isn't already defined as returning aPromise, it will be transformed to return aPromise- otherwise its return type will be left unchanged. This transformation is necessary because all function calls over astream.Duplex(e.g., anet.Socket) are asynchronous. Please see the Bi-directional Type-safe APIs example for how to easily consume a<Async<T>>in your application.
Errors:
The following Errors may arise when a Service API method is called.
- If the remote Service App method throws an
Error, theErrorwill be marshalled back from the Service and thePromisewill reject with theErroras its reason. - If a call exceeds the
egressQueueSizeLimit, thePromisewill reject withQueueSizeLimitErroras its reason and the stream will close. - If an
errorevent occurs on thestream.Duplex, thePromisewill reject with the given reason. - If the
stream.Duplexcloses, thePromisewill reject withStreamClosedErroras its reason. - If the
pathsarray is defined in the remoteServiceAppOptions<T>and a method is called that is not a registered property path, thePromisewill reject withPropertyPathErroras its reason. - If a property is invoked that is not a function on the remote Service App, the
Promisewill reject withTypeErroras its reason. - If the call fails to resolve or reject prior to the
timeoutspecified inServiceAPIOptions, thePromisewill reject withCallTimeoutErroras its reason.
NB The Service API and type safety is not enforced at runtime. Please see the
pathsproperty of theServiceAppOptions<T>object for runtime checks.
options<ServicePoolOptions>workerCount<number>Optional argument that specifies the number of worker threads to be spawned.workerURL<string | URL>The URL or path to the.jsmodule file. This is the module that will be scaled according to the value specified forworkerCount.restartWorkerOnError<boolean>A boolean setting specifying if Workers should be restarted onerror. Default:falseworkerOptions<worker_threads.WorkerOptions>Optionalworker_threads.WorkerOptionsto be passed to each Worker instance.
Returns: <ServicePool>
port<worker_threads.MessagePort | worker_threads.Worker>An optionalMessagePortto be wrapped by astream.Duplex. Default:worker_threads.parentPortoptions<internal.DuplexOptions>An optionalinternal.DuplexOptionsobject to be passed to thePortStreamparent class.
Returns: <PortStream>
A PortStream defaults to wrapping the parentPort of the Worker thread into a stream.Duplex. Hence, a PortStream is a stream.Duplex, so it can be passed to the Network-Services createService helper function. This is the stream adapter that is used in the Worker modules that comprise a Service Pool.
Network-Services provides a facility for building a type-safe network API. The type-safe API facility is realized through use of JavaScript's Proxy object and TypeScript's type variables. A Proxy interface is created by passing your app's public interface to the type parameter of the service.createServiceAPI<T> helper function. The type-safe Proxy interface facilitates code completion, parameter types, and return types; it helps safeguard the integrity of your API.
Please see the Bi-directional Type-safe APIs example for a working implementation.
Network-Services is modeled around communication over net.Sockets; however, it can be used in order to communicate over any resource that implements the stream.Duplex interface. This means that if you can model your bi-directional resource as a stream.Duplex, it should work with Network-Services. The createService helper function takes a stream.Duplex as its first argument. Just implement a stream.Duplex around your resource and pass it into the createService helper function.
The Scalability package, for example, uses Network-Services in order to scale an arbitrary Service App using Worker threads.
Network-Services is architected in order to support a variety of scaling models. The model implemented by the utility Service Pool facility, which supports request-response messaging, is just one of many possible approaches to scaling an application built on Network-Services.
For example, a Service Pool implementation where there is a one-to-one relationship between Service APIs and Service Apps would facilitate bi-directional communication. An alternative approach may be to run multiple servers in separate processes or Worker threads, connect to each of them, and round-robin through the respective Service APIs. Likewise, a container orchestration framework could be used in order to easily scale a Network-Services app.
Complexities arise when muxing many-to-many relationships; hence, please see the simple and capable ServicePool implementation for relevant considerations if you wish to draft a custom implementation.
Please see the Scalable "Hello, World!" example for a working scalable Service implementation using a Service Pool.
Network-Services provides a default minimalist JSON message protocol. However, you can marshal your messages however you choose by extending the BufferMux class and implementing theserializeMessage and deserializeMessage methods. Simply pass your custom Mux implementation in the ServiceOptions when you create your Service. Please see the muxClass parameter in ServiceOptions of the createService helper function.
Network-Services provides a concise default JSON message protocol. The message protocol is guided by parsimony; it includes just what is needed to make a function call and return a result or throw an Error.
Arguments, return values, and Errors are serialized using JavaScript's JSON.stringify. The choice of using JSON.stringify has important and certain ramifications that should be understood. Please see the rules for serialization.
The type definitions for the default JSON message protocol:
A CallMessageList consists of a numeric message type, the call identifier, the property path to the called function, and its arguments.
type CallMessageList = [
0, // The message type; 0 = Call.
string, // The call identifier.
Array<string>, // The elements of the property path to the called method.
...Array<unknown> // The arguments to be passed to the function.
];A ResultMessageList consists of a numeric message type, the call identifier, and a return value or Error.
type ResultMessageList = [
1 | 2, // The message type; 1 = Error, 2 = Result.
string, // The call identifier.
unknown // The return value or Error.
];You can pass your application's class as a type variable argument to the service.createServiceAPI<T> helper function; however, it's advisable to define a public interface instead. You can publish your public interface to be consumed separately or export it from your application.
For example, for the Greeter class in the "Hello, World!" example, the interface:
interface IGreeter {
greet(kind: string): string;
}Calling a method on a remote Service App using a Service API may take too long to resolve or reject - or may never resolve at all. This effect can be caused by a long running operation in the remote Service App or a congested network. If the call fails to resolve or reject prior to the timeout specified in ServiceAPIOptions, the Promise will reject with CallTimeoutError as its reason.
Unless you control the definition of both the Service API and the Service App, you should specify which methods may be called on your Service App using the paths property of ServiceAppOptions<T>.
Ensure your stream.Duplex (e.g., a net.Socket) is ready for use.
Network-Services assumes that the stream.Duplex passed to createService is ready to use; this assumption and separation of concern is an intentional design decision. A stream.Duplex implementation (e.g., a net.Socket) may include an event (e.g., something like 'ready' or 'connect') that will indicate it is ready for use. Please await this event, if available, prior to passing the stream.Duplex to the createService helper function.
A stream.Duplex may error even before becoming ready; hence, as usual, you should synchronously set an error handler on a new stream instance.
The object graph of a Service instance is rooted on its stream. It will begin decomposition immediately upon stream closure. However, in order to fully dispose of a Service instance, simply destroy and dereference its stream; GC will sweep buffers and other liminal resources.
Security is a complex and multifaceted endeavor.
TLS Encryption may be implemented using native Node.js TLS Encryption. Please see the TLS Encryption and Client Authentication example for a working implementation.
TLS Client Certificate Authentication may be implemented using native Node.js TLS Client Authentication. Please see the TLS Encryption and Client Authentication example for a working implementation.
The Service API and type safety are not enforced at runtime. You can restrict API calls to specified Service App methods by providing an Array of property paths to the paths property of the ServiceAppOptions<T> object. If the paths array is defined in ServiceAppOptions<T> and a method is called that is not a registered property path, the awaited Promise will reject with PropertyPathError as its reason.
Network-Services respects backpressure; however, it is advisable to specify how much data may be buffered in order to ensure your application can respond to adverse network phenomena. If the stream peer reads data at a rate that is slower than the rate that data is written to the stream, data may buffer until memory is exhausted. This is a vulnerability that is inherent in streams, which can be mitigated by preventing internal buffers from growing too large.
You can specify a hard limit on ingress and egress buffers in the Service options. ingressQueueSizeLimit specifies a limit on incoming data i.e., data returned from the remote Service App or calls from the remote Service API. egressQueueSizeLimit specifies a limit on outgoing data i.e., data returned to the remote Service API or calls to the remote Service App. If an ingress or egress buffer exceeds the specified limit, the respective stream will error and close. Network-Services will immediately tear down its internal buffers in order to free memory - dereference the stream, and GC will sweep.
The Network-Services package adheres to semantic versioning. Breaking changes to the public API will result in a turn of the major. Minor and patch changes will always be backward compatible.
Excerpted from Semantic Versioning 2.0.0:
Given a version number MAJOR.MINOR.PATCH, increment the:
- MAJOR version when you make incompatible API changes
- MINOR version when you add functionality in a backward compatible manner
- PATCH version when you make backward compatible bug fixes
Additional labels for pre-release and build metadata are available as extensions to the MAJOR.MINOR.PATCH format.
npm install && npm updatenpm testIf you have a feature request or run into any issues, feel free to submit an issue or start a discussion. You’re also welcome to reach out directly to one of the authors.
