├── .coveragebadgesrc ├── .editorconfig ├── .eslintrc.json ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .nycrc ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── package.json ├── src ├── codec.js ├── errors.js ├── id-generator.js ├── index.js └── request.js └── tests ├── benchmark.js ├── create.js └── index.test.js /.coveragebadgesrc: -------------------------------------------------------------------------------- 1 | { 2 | "source": "./coverage/coverage-summary.json", 3 | "attribute": "total.statements.pct", 4 | "outputDir": "./coverage/badges" 5 | } 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "standard", 3 | "env": { 4 | "node": true, 5 | "browser": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | push: 4 | branches: [ main ] 5 | pull_request: 6 | branches: [ main ] 7 | jobs: 8 | test: 9 | timeout-minutes: 60 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: 16 16 | - name: Install dependencies 17 | run: npm install 18 | - name: Run tests 19 | run: npm test 20 | coverage: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v3 24 | - uses: actions/setup-node@v3 25 | with: 26 | node-version: 16 27 | - run: npm install 28 | - run: npm run coverage 29 | - run: npm run make-badge 30 | - name: Publish coverage report to GitHub Pages 31 | uses: JamesIves/github-pages-deploy-action@v4 32 | with: 33 | folder: coverage 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | jsconfig.json 64 | package-lock.json 65 | dist 66 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "reporter": [ 3 | "html", 4 | "json-summary", 5 | "text" 6 | ], 7 | "lines": 95, 8 | "branches": "82", 9 | "statements": "95" 10 | } 11 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to nanomessage 2 | 3 | ## Issue Contributions 4 | 5 | When opening new issues or commenting on existing issues on this repository 6 | please make sure discussions are related to concrete technical issues. 7 | 8 | Try to be *friendly* (we are not animals :monkey: or bad people :rage4:) and explain correctly how we can reproduce your issue. 9 | 10 | ## Code Contributions 11 | 12 | This document will guide you through the contribution process. 13 | 14 | ### Step 1: Fork 15 | 16 | Fork the project [on GitHub](https://github.com/geut/nanomessage) and check out your copy locally. 17 | 18 | ```bash 19 | $ git clone git@github.com:username/nanomessage.git 20 | $ cd nanomessage 21 | $ npm install 22 | $ git remote add upstream git://github.com/geut/nanomessage.git 23 | ``` 24 | 25 | ### Step 2: Branch 26 | 27 | Create a feature branch and start hacking: 28 | 29 | ```bash 30 | $ git checkout -b my-feature-branch -t origin/main 31 | ``` 32 | 33 | ### Step 3: Test 34 | 35 | Bug fixes and features **should come with tests**. We use [uvu](https://github.com/lukeed/uvu) to do that. 36 | 37 | ```bash 38 | $ npm test 39 | ``` 40 | 41 | ### Step 4: Lint 42 | 43 | Make sure the linter is happy and that all tests pass. Please, do not submit 44 | patches that fail either check. 45 | 46 | We use [standard](https://standardjs.com/) 47 | 48 | ### Step 5: Commit 49 | 50 | Make sure git knows your name and email address: 51 | 52 | ```bash 53 | $ git config --global user.name "Bruce Wayne" 54 | $ git config --global user.email "bruce@batman.com" 55 | ``` 56 | 57 | Writing good commit logs is important. A commit log should describe what 58 | changed and why. 59 | 60 | ### Step 6: Changelog 61 | 62 | If your changes are really important for the project probably the users want to know about it. 63 | 64 | We use [chan](https://github.com/geut/chan/) to maintain a well readable changelog for our users. 65 | 66 | ### Step 7: Push 67 | 68 | ```bash 69 | $ git push origin my-feature-branch 70 | ``` 71 | 72 | ### Step 8: Make a pull request ;) 73 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 GEUT 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nanomessage (aka nm) 2 | 3 | ![Test Status](https://github.com/geut/nanomessage/actions/workflows/test.yml/badge.svg) 4 | [![Coverage](https://raw.githubusercontent.com/geut/nanomessage/gh-pages/badges/coverage.svg?raw=true)](https://geut.github.io/nanomessage/) 5 | [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) 6 | [![standard-readme compliant](https://img.shields.io/badge/readme%20style-standard-brightgreen.svg?style=flat-square)](https://github.com/RichardLitt/standard-readme) 7 | 8 | > Simple module that helps you to build a `request-response` abstraction on top of any other solution (e.g. streams). 9 | 10 | ## Install 11 | 12 | ``` 13 | $ npm install nanomessage 14 | ``` 15 | 16 | ## Usage 17 | 18 | ```javascript 19 | import WebSocket from 'ws' 20 | 21 | import { Nanomessage } from 'nanomessage' 22 | 23 | // server.js 24 | const server = new WebSocket.Server({ port: 3000 }) 25 | server.on('connection', function connection (ws) { 26 | const nm = new Nanomessage({ 27 | subscribe (ondata) { 28 | // Define how to read data 29 | ws.on('message', ondata) 30 | }, 31 | send (msg) { 32 | // Define how to send data 33 | ws.send(msg) 34 | }, 35 | onMessage (msg, opts) { 36 | // Process the new request and return a response 37 | console.log(msg) 38 | return 'pong from Alice' 39 | } 40 | }) 41 | 42 | nm.open().catch(err => console.error(err)) 43 | }) 44 | 45 | // client.js 46 | const ws = new WebSocket('ws://127.0.0.1:3000') 47 | const Bob = new Nanomessage({ 48 | async open() { 49 | if (ws.readyState === 0) { 50 | await new Promise(resolve => ws.once('open', resolve)) 51 | } 52 | }, 53 | subscribe (ondata) { 54 | ws.on('message', ondata) 55 | }, 56 | send (msg) { 57 | ws.send(msg) 58 | } 59 | }) 60 | 61 | ;(async () => { 62 | await Bob.open() 63 | console.log(await Bob.request('ping from Bob')) 64 | })() 65 | ``` 66 | 67 | ## API 68 | 69 | #### `const nm = new Nanomessage(options)` 70 | 71 | Create a new nanomessage. 72 | 73 | Options include: 74 | 75 | - `send: (chunk: Buffer, info: Object) => (Promise|undefined)`: Defines how to send the messages provide it by nanomessage to the low level solution. 76 | - `subscribe: (onData: buf => Promise) => UnsubscribeFunction`: Defines how to read data from the low level solution. 77 | - `onMessage: (msg: *, info: Object) => Promise`: Async handler to process the incoming requests. 78 | - `open: () => Promise`: Defines a function to run before the nanomessage instance is opened. 79 | - `close: () => Promise`: Defines a function to run after the nanomessage instance was close. 80 | - `timeout: null`: Time to wait for the response of a request. Disabled by default. 81 | - `concurrency: { incoming: 256, outgoing: 256 }`: Defines how many requests do you want to run (outgoing) and process (incoming) in concurrent. 82 | - `valueEncoding: msgpackr`: Defines a [compatible codec](https://github.com/mafintosh/codecs) to encode/decode messages in nanomessage. By default use: [msgpackr](https://github.com/kriszyp/msgpackr) 83 | 84 | `info` is an object with: 85 | 86 | - `info.id: Number`: Incremental ID request. 87 | - `info.data: *`: Plain data to send. 88 | - `info.ephemeral: boolean`: It's true if the message is ephemeral. 89 | - `info.response: boolean`: It's true if the message is a response. 90 | - `info.responseData: *`: Plain data to response. 91 | 92 | You can also extend from this prototype if you prefer: 93 | 94 | ```javascript 95 | const { Nanomessage } = require('nanomessage') 96 | 97 | class CustomNanomessage exports Nanomessage { 98 | constructor (...args) { 99 | super(...args) 100 | } 101 | 102 | _subscribe (onData) {} 103 | 104 | async _send (chunk, info) {} 105 | 106 | async _onMessage (msg, info) {} 107 | 108 | async _open() { 109 | await super._open() 110 | } 111 | 112 | async _close () { 113 | await super._close() 114 | } 115 | } 116 | ``` 117 | 118 | #### `nm.requests: Array` 119 | 120 | Get the current list of requests (inflight and pending). 121 | 122 | #### `nm.inflightRequests: Number` 123 | 124 | Number of requests processing in the queue. 125 | 126 | #### `nm.requestTimeout: Number` 127 | 128 | Get the current request timeout. 129 | 130 | #### `nm.concurrency: { incoming: Number, outgoing: Number }` 131 | 132 | Get the current concurrency. 133 | 134 | #### `nm.setRequestsTimeout(Number)` 135 | 136 | Change the timeout for the future requests. 137 | 138 | #### `nm.setConcurrency(Number | { incoming: Number, outgoing: Number })` 139 | 140 | Update the concurrency number of operations for incoming and outgoing requests. 141 | 142 | #### `nm.open() => Promise` 143 | 144 | Opens nanomessage and start listening for incoming data. 145 | 146 | #### `nm.close() => Promise` 147 | 148 | Closes nanomessage and unsubscribe from incoming data. 149 | 150 | #### `nm.request(data, [opts]) => Promise` 151 | 152 | Send a request and wait for a response. `data` can be any serializable type supported by your codec. 153 | 154 | - `opts.timeout: number`: Define a custom timeout for the current request. 155 | - `opts.signal: AbortSignal`: Set an abort signal object to cancel the request. 156 | 157 | #### `nm.send(data) => Promise` 158 | 159 | Send a `ephemeral` message. `data` can be any serializable type supported by your codec. 160 | 161 | #### `nm.processIncomingMessage(buf: Buffer) => Promise` 162 | 163 | Access directly to the handler of incoming messages. It's recommended to use the subscription model instead. 164 | 165 | #### `nm.setMessageHandler(handler) => Nanomessage` 166 | 167 | Defines a request handler. It will override the old handler. 168 | 169 | ## Issues 170 | 171 | :bug: If you found an issue we encourage you to report it on [github](https://github.com/geut/nanomessage/issues). Please specify your OS and the actions to reproduce it. 172 | 173 | ## Contributing 174 | 175 | :busts_in_silhouette: Ideas and contributions to the project are welcome. You must follow this [guideline](https://github.com/geut/nanomessage/blob/main/CONTRIBUTING.md). 176 | 177 | ## License 178 | 179 | MIT © A [**GEUT**](http://geutstudio.com/) project 180 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nanomessage", 3 | "version": "11.1.1", 4 | "description": "Simple module that helps you to build a `request-response` abstraction on top of any other solution (e.g. streams).", 5 | "type": "module", 6 | "main": "./dist/index.cjs", 7 | "exports": { 8 | ".": { 9 | "require": "./dist/index.cjs", 10 | "import": "./src/index.js" 11 | }, 12 | "./package.json": "./package.json" 13 | }, 14 | "files": [ 15 | "dist", 16 | "src" 17 | ], 18 | "scripts": { 19 | "benchmark": "node tests/benchmark.js", 20 | "build": "tsup", 21 | "test": "uvu -i create -i benchmark", 22 | "posttest": "npm run lint", 23 | "lint": "standard", 24 | "prepublishOnly": "npm test && npm run build", 25 | "coverage": "c8 uvu -i create -i benchmark", 26 | "make-badge": "coverage-badges" 27 | }, 28 | "dependencies": { 29 | "fastq": "^1.8.0", 30 | "msgpackr": "^1.6.3", 31 | "nanocustomassert": "^1.0.0", 32 | "nanoerror": "^1.3.0", 33 | "nanoresource-promise": "^3.1.0" 34 | }, 35 | "devDependencies": { 36 | "abortcontroller-polyfill": "^1.7.3", 37 | "c8": "^7.12.0", 38 | "coverage-badges": "^1.0.7", 39 | "standard": "^17.0.0", 40 | "streamx": "^2.6.4", 41 | "tinybench": "^2.3.1", 42 | "tinyspy": "^1.0.2", 43 | "tsup": "^6.3.0", 44 | "uvu": "^0.5.6" 45 | }, 46 | "standard": { 47 | "env": [ 48 | "node", 49 | "browser" 50 | ] 51 | }, 52 | "tsup": { 53 | "entry": [ 54 | "src/index.js" 55 | ], 56 | "format": [ 57 | "cjs", 58 | "iife" 59 | ], 60 | "globalName": "Nanomessage", 61 | "splitting": false, 62 | "sourcemap": true, 63 | "clean": true 64 | }, 65 | "repository": { 66 | "type": "git", 67 | "url": "git+https://github.com/geut/nanomessage.git" 68 | }, 69 | "keywords": [ 70 | "nano", 71 | "geut", 72 | "request", 73 | "request-response", 74 | "websocket", 75 | "socket", 76 | "stream" 77 | ], 78 | "author": { 79 | "name": "GEUT", 80 | "email": "contact@geutstudio.com" 81 | }, 82 | "license": "MIT", 83 | "bugs": { 84 | "url": "https://github.com/geut/nanomessage/issues" 85 | }, 86 | "homepage": "https://github.com/geut/nanomessage#readme", 87 | "publishConfig": { 88 | "access": "public" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/codec.js: -------------------------------------------------------------------------------- 1 | import { Packr } from 'msgpackr' 2 | 3 | import { RequestInfo } from './request.js' 4 | import { 5 | encodeError, 6 | decodeError, 7 | NM_ERR_ENCODE, 8 | NM_ERR_DECODE 9 | } from './errors.js' 10 | 11 | const ATTR_RESPONSE = 1 12 | const ATTR_ERROR = 1 << 1 13 | 14 | export const createPackr = (opts = {}) => { 15 | return new Packr({ 16 | useRecords: true, 17 | ...opts 18 | }) 19 | } 20 | 21 | const staticPackr = createPackr({ 22 | structures: [ 23 | ['id', 'header', 'data'], 24 | ['code', 'unformatMessage', 'args', 'metadata'] 25 | ] 26 | }) 27 | 28 | const dynamicPackr = createPackr() 29 | const defaultValueEncoding = { 30 | encode: (data) => dynamicPackr.pack(data), 31 | decode: (data) => dynamicPackr.unpack(data) 32 | } 33 | 34 | export function createCodec (valueEncoding = defaultValueEncoding) { 35 | return { 36 | encode (info) { 37 | try { 38 | let header = 0 39 | if (info.response) header = header | ATTR_RESPONSE 40 | if (info.error) header = header | ATTR_ERROR 41 | 42 | let data = info.response ? info.responseData : info.data 43 | data = info.error ? encodeError(data) : data 44 | const buf = staticPackr.pack({ 45 | id: info.id, 46 | header, 47 | data: info.error ? staticPackr.pack(data) : valueEncoding.encode(data) 48 | }) 49 | return buf 50 | } catch (err) { 51 | throw NM_ERR_ENCODE.from(err) 52 | } 53 | }, 54 | 55 | decode (buf, context) { 56 | try { 57 | const { id, header, data } = staticPackr.unpack(buf) 58 | 59 | const response = !!(header & ATTR_RESPONSE) 60 | const error = !!(header & ATTR_ERROR) 61 | 62 | return new RequestInfo( 63 | id, 64 | response, 65 | error, 66 | context, 67 | null, 68 | () => { 69 | if (error) return decodeError(staticPackr.unpack(data)) 70 | return valueEncoding.decode(data) 71 | } 72 | ) 73 | } catch (err) { 74 | throw NM_ERR_DECODE.from(err) 75 | } 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/errors.js: -------------------------------------------------------------------------------- 1 | import nanoerror from 'nanoerror' 2 | 3 | const errors = new Map() 4 | 5 | export function createError (code, message, persist = false) { 6 | const err = nanoerror(code, message) 7 | if (persist) errors.set(code, err) 8 | return err 9 | } 10 | 11 | export function encodeError (err) { 12 | return { 13 | code: err.code, 14 | unformatMessage: err.unformatMessage, 15 | args: err.args, 16 | metadata: err.metadata 17 | } 18 | } 19 | 20 | export function decodeError ({ code, unformatMessage, args = [], metadata = {} }) { 21 | const ErrorDecoded = errors.get(code) || nanoerror(code, unformatMessage) 22 | const err = new ErrorDecoded(...args) 23 | err.metadata = metadata 24 | return err 25 | } 26 | 27 | export const NM_ERR_TIMEOUT = createError('NM_ERR_TIMEOUT', 'timeout on request: %s', true) 28 | export const NM_ERR_ENCODE = createError('NM_ERR_ENCODE', 'error encoding the request: %s', true) 29 | export const NM_ERR_DECODE = createError('NM_ERR_DECODE', 'error decoding the request: %s', true) 30 | export const NM_ERR_MESSAGE = createError('NM_ERR_MESSAGE', 'on message error: %s', true) 31 | export const NM_ERR_REMOTE_RESPONSE = createError('NM_ERR_REMOTE_RESPONSE', 'remote response error: %s', true) 32 | export const NM_ERR_CLOSE = createError('NM_ERR_CLOSE', 'nanomessage was closed', true) 33 | export const NM_ERR_NOT_OPEN = createError('NM_ERR_NOT_OPEN', 'nanomessage is not open', true) 34 | export const NM_ERR_CANCEL = createError('NM_ERR_CANCEL', '%o', true) 35 | -------------------------------------------------------------------------------- /src/id-generator.js: -------------------------------------------------------------------------------- 1 | export default class IdGenerator { 2 | constructor (generate) { 3 | this._generate = generate 4 | this._free = [] 5 | } 6 | 7 | get () { 8 | if (!this._free.length) { 9 | return this._generate() 10 | } 11 | 12 | return this._free.pop() 13 | } 14 | 15 | release (id) { 16 | this._free.push(id) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { NanoresourcePromise } from 'nanoresource-promise/emitter2' 2 | import fastq from 'fastq' 3 | 4 | import Request from './request.js' 5 | import { createCodec, createPackr } from './codec.js' 6 | import { 7 | NM_ERR_CLOSE, 8 | NM_ERR_NOT_OPEN, 9 | NM_ERR_MESSAGE, 10 | NM_ERR_REMOTE_RESPONSE 11 | } from './errors.js' 12 | import IdGenerator from './id-generator.js' 13 | 14 | const VOID_RESPONSE = Symbol('VOID_RESPONSE') 15 | const kRequests = Symbol('nanomessage.requests') 16 | const kInQueue = Symbol('nanomessage.inqueue') 17 | const kOutQueue = Symbol('nanomessage.outqueue') 18 | const kUnsubscribe = Symbol('nanomessage.unsubscribe') 19 | const kOpen = Symbol('nanomessage.open') 20 | const kClose = Symbol('nanomessage.close') 21 | const kFastCheckOpen = Symbol('nanomessage.fastcheckopen') 22 | const kTimeout = Symbol('nanomessage.timeout') 23 | const kIdGenerator = Symbol('nanomessage.idgenerator') 24 | const kCodec = Symbol('nanomessage.codec') 25 | 26 | function inWorker ({ info, onMessage }, done) { 27 | this[kFastCheckOpen]() 28 | .then(() => onMessage(info.data, info)) 29 | .catch(_err => { 30 | if (_err.isNanoerror) return _err 31 | const err = NM_ERR_REMOTE_RESPONSE.from(_err) 32 | err.metadata = _err.metadata 33 | return err 34 | }) 35 | .then(async data => { 36 | if (VOID_RESPONSE === data || this.closed || this.closing) return 37 | 38 | info.response = true 39 | info.responseData = data 40 | info.error = !!(data?.isNanoerror) 41 | 42 | await this._send(this[kCodec].encode(info), info) 43 | 44 | if (info.error) throw data 45 | }) 46 | .then(() => done()) 47 | .catch(err => { 48 | if (err.isNanoerror) return done(err) 49 | done(NM_ERR_MESSAGE.from(err)) 50 | }) 51 | } 52 | 53 | function outWorker (request, done) { 54 | const info = request.info() 55 | this[kFastCheckOpen]() 56 | .then(() => { 57 | if (request.finished) return 58 | request.start() 59 | return this._send(this[kCodec].encode(info), info) 60 | }) 61 | .then(() => { 62 | if (request.finished) return 63 | return request.promise 64 | }) 65 | .then(data => done(null, data)) 66 | .catch(err => done(err)) 67 | } 68 | 69 | export * from './errors.js' 70 | 71 | export { createPackr, VOID_RESPONSE } 72 | export class Nanomessage extends NanoresourcePromise { 73 | /** 74 | * Creates an instance of Nanomessage. 75 | * @param {Object} [opts={}] 76 | * @param {(buf: Buffer, info: Object) => Promise|undefined} [opts.send] 77 | * @param {function} [opts.subscribe] 78 | * @param {(data: Object, info: Object) => Promise<*>} [opts.onMessage] 79 | * @param {function} [opts.open] 80 | * @param {function} [opts.close] 81 | * @param {number} [opts.timeout] 82 | * @param {Object} [opts.valueEncoding] 83 | * @param {({ incoming: number, outgoing: number }|number)} [opts.concurrency] 84 | * @memberof Nanomessage 85 | */ 86 | constructor (opts = {}) { 87 | super() 88 | 89 | const { send, subscribe, onMessage, open, close, timeout, valueEncoding, concurrency = 256 } = opts 90 | 91 | if (send) this._send = send 92 | if (subscribe) this._subscribe = subscribe 93 | if (onMessage) this.setMessageHandler(onMessage) 94 | if (open) this[kOpen] = open 95 | if (close) this[kClose] = close 96 | this.setRequestTimeout(timeout) 97 | 98 | this[kCodec] = createCodec(valueEncoding) 99 | 100 | this[kInQueue] = fastq(this, inWorker, 1) 101 | this[kOutQueue] = fastq(this, outWorker, 1) 102 | this.setConcurrency(concurrency) 103 | 104 | this[kRequests] = new Map() 105 | this[kIdGenerator] = new IdGenerator(() => this[kRequests].size + 1) 106 | } 107 | 108 | /** 109 | * @readonly 110 | * @type {Object} 111 | */ 112 | get codec () { 113 | return this[kCodec] 114 | } 115 | 116 | /** 117 | * @readonly 118 | * @type {Array} 119 | */ 120 | get requests () { 121 | return Array.from(this[kRequests].values()) 122 | } 123 | 124 | /** 125 | * @readonly 126 | * @type {number} 127 | */ 128 | get inflightRequests () { 129 | return this[kOutQueue].running() 130 | } 131 | 132 | /** 133 | * @readonly 134 | * @type {number} 135 | */ 136 | get requestTimeout () { 137 | return this[kTimeout] 138 | } 139 | 140 | /** 141 | * @readonly 142 | * @type {Object} 143 | */ 144 | get concurrency () { 145 | return { 146 | incoming: this[kInQueue].concurrency, 147 | outgoing: this[kOutQueue].concurrency 148 | } 149 | } 150 | 151 | /** 152 | * @param {number} timeout 153 | * @returns {Nanomessage} 154 | */ 155 | setRequestTimeout (timeout) { 156 | this[kTimeout] = timeout 157 | return this 158 | } 159 | 160 | /** 161 | * @param {({ incoming: number, outgoing: number }|number)} value 162 | * @returns {Nanomessage} 163 | */ 164 | setConcurrency (value) { 165 | if (typeof value === 'number') { 166 | this[kInQueue].concurrency = value 167 | this[kOutQueue].concurrency = value 168 | } else { 169 | this[kInQueue].concurrency = value.incoming || this[kInQueue].concurrency 170 | this[kOutQueue].concurrency = value.outgoing || this[kOutQueue].concurrency 171 | } 172 | return this 173 | } 174 | 175 | /** 176 | * Send a request and wait for the response. 177 | * 178 | * @param {*} data 179 | * @param {Object} [opts] 180 | * @param {number} [opts.timeout] 181 | * @param {AbortSignal} [opts.signal] 182 | * @param {function} [opts.onCancel] 183 | * @param {*} [opts.context] 184 | * @returns {Promise<*>} 185 | */ 186 | async request (data, opts = {}) { 187 | if (this.closed || this.closing) throw new NM_ERR_CLOSE() 188 | 189 | const request = new Request({ id: this[kIdGenerator].get(), data, timeout: opts.timeout || this[kTimeout], signal: opts.signal, context: opts.context, onCancel: opts.onCancel }) 190 | const info = request.info() 191 | 192 | this[kRequests].set(request.id, request) 193 | request.onFinish(() => { 194 | this[kRequests].delete(request.id) 195 | this[kIdGenerator].release(request.id) 196 | }) 197 | 198 | this.emit('request-created', info) 199 | 200 | this[kOutQueue].push(request, (err, data) => { 201 | if (err) request.reject(err) 202 | info.response = true 203 | info.responseData = data 204 | this.emit('request-ended', err, info) 205 | }) 206 | 207 | return request.promise 208 | } 209 | 210 | /** 211 | * Send a ephemeral message. 212 | * 213 | * @param {*} data 214 | * @param {Object} [opts] 215 | * @param {Object} [opts.context] 216 | * @returns {Promise} 217 | */ 218 | send (data, opts = {}) { 219 | return this[kFastCheckOpen]() 220 | .then(() => { 221 | const info = Request.info({ id: 0, data, context: opts.context }) 222 | return this._send(this[kCodec].encode(info), info) 223 | }) 224 | } 225 | 226 | /** 227 | * @param {(data: Object, info: Object) => Promise<*>} onMessage 228 | * @returns {Nanomessage} 229 | */ 230 | setMessageHandler (onMessage) { 231 | this._onMessage = onMessage 232 | return this 233 | } 234 | 235 | /** 236 | * @param {Buffer} buf 237 | * @param {Object} [opts] 238 | * @param {function} [opts.onMessage] 239 | * @param {*} [opts.context] 240 | * @returns {Promise} 241 | */ 242 | async processIncomingMessage (buf, opts = {}) { 243 | if (this.closed || this.closing) return 244 | 245 | const { onMessage = this._onMessage, context } = opts 246 | 247 | const info = this[kCodec].decode(buf, context) 248 | 249 | // resolve response 250 | if (info.response) { 251 | const request = this[kRequests].get(info.id) 252 | if (request) { 253 | if (info.error) { 254 | request.reject(info.data) 255 | } else { 256 | request.resolve(info.data) 257 | } 258 | } 259 | return new Promise(resolve => resolve(info)) 260 | } 261 | 262 | this.emit('message', info) 263 | 264 | if (info.ephemeral) { 265 | return this[kFastCheckOpen]() 266 | .then(() => onMessage(info.data, info)) 267 | .then(() => info) 268 | .catch(err => { 269 | if (!err.isNanoerror) { 270 | err = NM_ERR_MESSAGE.from(err) 271 | } 272 | this.emit('error-message', err, info) 273 | throw err 274 | }) 275 | } 276 | 277 | return new Promise((resolve, reject) => this[kInQueue].push({ info, onMessage }, err => { 278 | if (err) { 279 | this.emit('error-message', err, info) 280 | reject(err) 281 | } else { 282 | resolve(info) 283 | } 284 | })) 285 | } 286 | 287 | /** 288 | * @abstract 289 | * @param {Buffer} buf 290 | * @param {Object} info 291 | * @returns {Promise|undefined} 292 | */ 293 | async _send (buf, info) { 294 | throw new Error('_send not implemented') 295 | } 296 | 297 | /** 298 | * @abstract 299 | * @param {Object} data 300 | * @param {Object} info 301 | * @returns {Promise<*>} 302 | */ 303 | async _onMessage (data, info) {} 304 | 305 | async _open () { 306 | await (this[kOpen] && this[kOpen]()) 307 | const opts = { 308 | onMessage: this._onMessage.bind(this) 309 | } 310 | const processIncomingMessage = buf => this.processIncomingMessage(buf, opts) 311 | this[kUnsubscribe] = this._subscribe && this._subscribe(processIncomingMessage) 312 | } 313 | 314 | async _close () { 315 | if (this[kUnsubscribe]) this[kUnsubscribe]() 316 | 317 | const requestsToClose = [] 318 | this[kRequests].forEach(request => request.reject(new NM_ERR_CLOSE())) 319 | this[kRequests].clear() 320 | 321 | this[kInQueue] && this[kInQueue].kill() 322 | this[kOutQueue] && this[kOutQueue].kill() 323 | 324 | await (this[kClose] && this[kClose]()) 325 | await Promise.all(requestsToClose) 326 | } 327 | 328 | async [kFastCheckOpen] () { 329 | if (this.closed || this.closing) throw new NM_ERR_CLOSE() 330 | if (this.opening) return this.open() 331 | if (!this.opened) throw new NM_ERR_NOT_OPEN() 332 | } 333 | } 334 | -------------------------------------------------------------------------------- /src/request.js: -------------------------------------------------------------------------------- 1 | import { NM_ERR_CANCEL, NM_ERR_TIMEOUT } from './errors.js' 2 | 3 | const kEmpty = Symbol('empty') 4 | export class RequestInfo { 5 | constructor (id, response = false, error = false, context = {}, data, getData) { 6 | this.id = id 7 | this.response = response 8 | this.ephemeral = id === 0 9 | this.error = error 10 | this.context = context 11 | this.responseData = undefined 12 | this._data = getData ? kEmpty : data 13 | this._getData = getData 14 | } 15 | 16 | get data () { 17 | if (this._data !== kEmpty) return this._data 18 | this._data = this._getData() 19 | return this._data 20 | } 21 | 22 | toJSON () { 23 | return { 24 | id: this.id, 25 | data: this.data, 26 | response: this.response, 27 | ephemeral: this.ephemeral, 28 | error: this.error, 29 | context: this.context, 30 | responseData: this.responseData 31 | } 32 | } 33 | } 34 | export default class Request { 35 | static info (obj = {}) { 36 | return new RequestInfo(obj.id, obj.response, obj.error, obj.context, obj.data) 37 | } 38 | 39 | constructor (opts = {}) { 40 | const { 41 | id, 42 | data, 43 | response = false, 44 | timeout, 45 | signal, 46 | context = {}, 47 | onCancel = (aborted = false) => new NM_ERR_CANCEL({ id, timeout, aborted, context }) 48 | } = opts 49 | 50 | this.id = id 51 | this.data = data 52 | this.response = response 53 | this.finished = false 54 | this.timeout = timeout 55 | this.context = context 56 | this.timer = null 57 | 58 | let _resolve, _reject 59 | this.promise = new Promise((resolve, reject) => { 60 | _resolve = resolve 61 | _reject = reject 62 | }) 63 | 64 | const onAbort = () => { 65 | this.reject(onCancel(true) || new NM_ERR_CANCEL({ id, timeout, aborted: true })) 66 | } 67 | 68 | if (signal) { 69 | if (signal.aborted) { 70 | queueMicrotask(onAbort) 71 | } else { 72 | signal.addEventListener('abort', onAbort) 73 | } 74 | } 75 | 76 | this.resolve = (data) => { 77 | if (!this.finished) { 78 | this.timer && clearTimeout(this.timer) 79 | signal && signal.removeEventListener('abort', onAbort) 80 | this.finished = true 81 | this._onFinish() 82 | _resolve(data) 83 | } 84 | } 85 | 86 | this.reject = (err) => { 87 | if (!this.finished) { 88 | this.timer && clearTimeout(this.timer) 89 | signal && signal.removeEventListener('abort', onAbort) 90 | this.finished = true 91 | this._onFinish(err) 92 | _reject(err) 93 | } 94 | } 95 | } 96 | 97 | start () { 98 | if (this.timeout) { 99 | this.timer = setTimeout(() => { 100 | this.reject(new NM_ERR_TIMEOUT(this.id)) 101 | }, this.timeout) 102 | } 103 | } 104 | 105 | onFinish (cb) { 106 | this._onFinish = cb 107 | } 108 | 109 | info () { 110 | return Request.info(this) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /tests/benchmark.js: -------------------------------------------------------------------------------- 1 | import { Bench } from 'tinybench' 2 | 3 | import create from './create.js' 4 | import { Nanomessage, createPackr } from '../src/index.js' 5 | 6 | const context = { 7 | basic: async () => { 8 | const [alice, bob] = create({ 9 | onMessage: () => ({ value: 'pong' }) 10 | }, { 11 | onMessage: () => ({ value: 'pong' }) 12 | }) 13 | await alice.open() 14 | await bob.open() 15 | return { alice, bob } 16 | }, 17 | sharedStructures: async () => { 18 | const packr = createPackr({ 19 | structures: [] 20 | }) 21 | 22 | const valueEncoding = { 23 | encode: (data) => packr.pack(data), 24 | decode: (data) => packr.unpack(data) 25 | } 26 | 27 | const [alice, bob] = create({ 28 | valueEncoding, 29 | onMessage: () => ({ value: 'pong' }) 30 | }, { 31 | valueEncoding, 32 | onMessage: () => ({ value: 'pong' }) 33 | }) 34 | await alice.open() 35 | await bob.open() 36 | return { alice, bob } 37 | } 38 | } 39 | 40 | const bench = new Bench({ 41 | time: 0, 42 | iterations: 10_000, 43 | setup: async (task) => { 44 | if (task.name.includes('sharedStructures')) { 45 | task.context = await context.sharedStructures() 46 | } else { 47 | task.context = await context.basic() 48 | } 49 | } 50 | }) 51 | 52 | bench 53 | .add('execute 10000 requests x 2 peers', async function () { 54 | const { alice } = this.context 55 | const res = await alice.request({ value: 'ping' }) 56 | if (res.value !== 'pong') throw new Error('wrong') 57 | }) 58 | .add('execute 10000 ephemeral messages x 2 peers', async function () { 59 | const { alice, bob } = this.context 60 | await alice.send('test') 61 | await Nanomessage.once(bob, 'message') 62 | }) 63 | .add('execute 10000 requests x 2 peers using sharedStructures', async function () { 64 | const { alice } = this.context 65 | const res = await alice.request({ value: 'ping' }) 66 | if (res.value !== 'pong') throw new Error('wrong') 67 | }) 68 | 69 | await bench.run() 70 | 71 | console.table( 72 | bench.tasks.map(({ name, result }) => (result 73 | ? ({ 74 | 'Task Name': name, 75 | 'ops/sec': parseInt(result.hz, 10), 76 | 'Total Time (ms)': Math.round(result.totalTime), 77 | 'Average Time (ns)': Math.round(result.mean * 1000 * 1000), 78 | Margin: `\xb1${result.rme.toFixed(2)}%`, 79 | Samples: result.samples.length 80 | }) 81 | : null)) 82 | ) 83 | -------------------------------------------------------------------------------- /tests/create.js: -------------------------------------------------------------------------------- 1 | import { Duplex } from 'streamx' 2 | 3 | import { Nanomessage } from '../src/index.js' 4 | 5 | function createFromStream (stream, options = {}) { 6 | const { onSend = () => {}, onClose = () => {}, ...nmOptions } = options 7 | 8 | const nm = new Nanomessage(Object.assign({ 9 | subscribe (ondata) { 10 | stream.on('data', (data) => { 11 | ondata(data).catch(err => { 12 | nm.emit('subscribe-error', err) 13 | }) 14 | }) 15 | 16 | return () => { 17 | nm.emit('unsubscribe') 18 | } 19 | }, 20 | send (chunk, info) { 21 | onSend(chunk, info) 22 | if (stream.destroyed) return 23 | stream.write(chunk) 24 | }, 25 | close () { 26 | onClose() 27 | if (stream.destroyed) return 28 | return new Promise(resolve => { 29 | stream.once('close', () => resolve()) 30 | stream.destroy() 31 | }) 32 | } 33 | }, nmOptions)) 34 | 35 | nm.open().catch((err) => { 36 | console.log(err) 37 | }) 38 | 39 | stream.on('close', () => { 40 | nm.close() 41 | }) 42 | 43 | nm.stream = stream 44 | 45 | return nm 46 | } 47 | 48 | export default function create (aliceOpts = { onMessage () {} }, bobOpts = { onMessage () {} }) { 49 | const stream1 = new Duplex({ 50 | write (data, cb) { 51 | stream2.push(data) 52 | cb() 53 | } 54 | }) 55 | const stream2 = new Duplex({ 56 | write (data, cb) { 57 | stream1.push(data) 58 | cb() 59 | } 60 | }) 61 | 62 | const alice = createFromStream(stream1, aliceOpts) 63 | const bob = createFromStream(stream2, bobOpts) 64 | 65 | return [alice, bob] 66 | } 67 | -------------------------------------------------------------------------------- /tests/index.test.js: -------------------------------------------------------------------------------- 1 | import { test } from 'uvu' 2 | import * as assert from 'uvu/assert' 3 | import { spy } from 'tinyspy' 4 | 5 | import { AbortController } from 'abortcontroller-polyfill/dist/abortcontroller.js' 6 | 7 | import create from './create.js' 8 | 9 | import { 10 | Nanomessage, 11 | createError, 12 | VOID_RESPONSE, 13 | NM_ERR_TIMEOUT, 14 | NM_ERR_CANCEL, 15 | NM_ERR_CLOSE, 16 | NM_ERR_MESSAGE, 17 | NM_ERR_NOT_OPEN, 18 | NM_ERR_REMOTE_RESPONSE, 19 | NM_ERR_ENCODE 20 | } from '../src/index.js' 21 | 22 | const assertThrow = async (p, ErrorClass) => { 23 | try { 24 | await p 25 | assert.unreachable('should have thrown') 26 | } catch (err) { 27 | if (ErrorClass.isNanoerror) { 28 | assert.ok(ErrorClass.equals(err), `expect code: ${ErrorClass.code} resolve: ${err}`) 29 | } else { 30 | assert.instance(err, ErrorClass) 31 | } 32 | } 33 | } 34 | 35 | test('basic', async () => { 36 | const onSend = spy((data, info) => { 37 | assert.ok(Buffer.isBuffer(data)) 38 | assert.is.not(info.id, undefined) 39 | if (!info.response) { 40 | assert.ok(info.context.optionalInformation) 41 | } 42 | 43 | return { ...info.toJSON(), id: undefined } 44 | }) 45 | 46 | const onError = spy() 47 | 48 | const [alice, bob] = create( 49 | { 50 | onMessage: spy((data, info) => { 51 | assert.equal(data, Buffer.from('ping from bob')) 52 | assert.is.not(info.id, undefined) 53 | assert.is(info.ephemeral, false) 54 | assert.is(info.response, false) 55 | assert.equal(info.data, data) 56 | return Buffer.from('pong from alice') 57 | }), 58 | onSend 59 | }, 60 | { 61 | onMessage: spy((data) => { 62 | assert.equal(data, 'ping from alice') 63 | return 'pong from bob' 64 | }) 65 | } 66 | ) 67 | 68 | alice.on('error-message', onError) 69 | bob.on('error-message', onError) 70 | 71 | assert.equal(await alice.request('ping from alice', { context: { optionalInformation: true } }), 'pong from bob') 72 | assert.equal(await bob.request(Buffer.from('ping from bob')), Buffer.from('pong from alice')) 73 | assert.ok(!onError.called, 'should not get a message error') 74 | 75 | assert.is(alice._onMessage.callCount, 1) 76 | assert.is(bob._onMessage.callCount, 1) 77 | 78 | assert.is(onSend.callCount, 2) 79 | assert.equal(onSend.results[0][1], { id: undefined, responseData: undefined, data: 'ping from alice', ephemeral: false, response: false, error: false, context: { optionalInformation: true } }) 80 | assert.equal(onSend.results[1][1], { id: undefined, data: Buffer.from('ping from bob'), responseData: Buffer.from('pong from alice'), ephemeral: false, response: true, error: false, context: {} }) 81 | }) 82 | 83 | test('options', () => { 84 | const [alice] = create({ timeout: 1000 }) 85 | 86 | assert.not.type(alice.codec, undefined) 87 | 88 | assert.is(alice.requestTimeout, 1000) 89 | 90 | assert.equal(alice.concurrency, { 91 | incoming: 256, 92 | outgoing: 256 93 | }) 94 | 95 | alice.setConcurrency({ 96 | incoming: 1, 97 | outgoing: 2 98 | }) 99 | assert.equal(alice.concurrency, { 100 | incoming: 1, 101 | outgoing: 2 102 | }) 103 | 104 | alice.setConcurrency({ 105 | incoming: 10 106 | }) 107 | assert.equal(alice.concurrency, { 108 | incoming: 10, 109 | outgoing: 2 110 | }) 111 | 112 | alice.setConcurrency({ 113 | outgoing: 15 114 | }) 115 | assert.equal(alice.concurrency, { 116 | incoming: 10, 117 | outgoing: 15 118 | }) 119 | }) 120 | 121 | test('timeout', async () => { 122 | const [alice] = create( 123 | { 124 | timeout: 100 125 | }, 126 | { 127 | onMessage: async () => { 128 | await new Promise(resolve => setTimeout(resolve, 200)) 129 | } 130 | } 131 | ) 132 | 133 | await assertThrow(alice.request('ping'), NM_ERR_TIMEOUT) 134 | }) 135 | 136 | test('automatic cleanup requests', async () => { 137 | const [alice, bob] = create({ 138 | onMessage () {} 139 | }, { 140 | onMessage () {} 141 | }) 142 | 143 | assert.is(alice.requests.length, 0) 144 | assert.is(bob.requests.length, 0) 145 | 146 | const aliceTen = Array.from(Array(10).keys()).map(() => alice.request('message')) 147 | const bobTen = Array.from(Array(10).keys()).map(() => bob.request('message')) 148 | 149 | assert.is(bob.requests.length, 10) 150 | assert.is(alice.requests.length, 10) 151 | 152 | await Promise.all([...aliceTen, ...bobTen]) 153 | 154 | assert.is(alice.requests.length, 0) 155 | assert.is(bob.requests.length, 0) 156 | }) 157 | 158 | test('close', async () => { 159 | const [alice, bob] = create() 160 | 161 | const request = bob.request('message') 162 | 163 | assert.is(bob.requests.length, 1) 164 | 165 | await Promise.all([ 166 | Nanomessage.once(alice, 'unsubscribe'), 167 | Nanomessage.once(bob, 'unsubscribe'), 168 | assertThrow(request, NM_ERR_CLOSE), 169 | alice.close(), 170 | bob.close() 171 | ]) 172 | 173 | assert.is(bob.requests.length, 0) 174 | await assertThrow(alice.send('test'), NM_ERR_CLOSE) 175 | }) 176 | 177 | test('detect invalid request', async () => { 178 | const [alice, bob] = create() 179 | 180 | queueMicrotask(() => bob.stream.write('not valid')) 181 | const [error] = await Nanomessage.once(alice, 'subscribe-error') 182 | assert.is(error.code, 'NM_ERR_DECODE') 183 | }) 184 | 185 | test('custom valueEncoding', async () => { 186 | const valueEncoding = { 187 | encode (data) { 188 | if (typeof data === 'number') throw new Error('test') 189 | return Buffer.from(data) 190 | }, 191 | decode (buf) { 192 | return buf.toString('utf8') 193 | } 194 | } 195 | 196 | const [alice] = create({ valueEncoding }, { valueEncoding, onMessage: () => 'hi' }) 197 | 198 | const bobMessage = await alice.request('hello') 199 | assert.is(bobMessage, 'hi') 200 | await assertThrow(alice.request(2), NM_ERR_ENCODE) 201 | }) 202 | 203 | test('void response', async () => { 204 | const [alice] = create({ onMessage: () => {} }, { onMessage: () => VOID_RESPONSE }) 205 | await assertThrow(alice.request('hello', { timeout: 100 }), NM_ERR_TIMEOUT) 206 | }) 207 | 208 | test('send ephemeral message', async () => { 209 | const onError = spy() 210 | 211 | const [alice, bob] = create( 212 | { 213 | onMessage: spy((data, { ephemeral }) => { 214 | assert.is(ephemeral, true) 215 | assert.equal(data, Buffer.from('ping from bob')) 216 | }) 217 | }, 218 | { 219 | onMessage: spy((data, { ephemeral }) => { 220 | assert.is(ephemeral, true) 221 | assert.is(data, 'ping from alice') 222 | }) 223 | } 224 | ) 225 | 226 | alice.on('error-message', onError) 227 | bob.on('error-message', onError) 228 | 229 | await Promise.all([ 230 | alice.send('ping from alice'), 231 | bob.send(Buffer.from('ping from bob')), 232 | Nanomessage.once(alice, 'message'), 233 | Nanomessage.once(bob, 'message') 234 | ]) 235 | 236 | await new Promise(resolve => setTimeout(resolve, 100)) 237 | 238 | assert.ok(!onError.called, 'should not get a message error') 239 | }) 240 | 241 | test('concurrency', async () => { 242 | const [alice, bob] = create( 243 | { 244 | concurrency: 2 245 | }, 246 | { 247 | concurrency: 2 248 | } 249 | ) 250 | 251 | alice.request('ping from alice').catch(() => {}) 252 | alice.request('ping from alice').catch(() => {}) 253 | alice.request('ping from alice').catch(() => {}) // this request will wait, only 2 inflightRequests 254 | 255 | assert.is(alice.inflightRequests, 2) 256 | 257 | await Promise.all([alice.close(), bob.close()]) 258 | }) 259 | 260 | test('abort signal', async () => { 261 | const [alice] = create( 262 | {}, 263 | { 264 | onMessage: async () => { 265 | await new Promise(resolve => setTimeout(resolve, 100)) 266 | return 'pong' 267 | } 268 | } 269 | ) 270 | 271 | const ErrorCustom = createError('CUSTOM') 272 | 273 | { 274 | const controller = new AbortController() 275 | const signal = controller.signal 276 | controller.abort() 277 | const request = alice.request('ping', { signal }) 278 | await assertThrow(request, NM_ERR_CANCEL) 279 | } 280 | 281 | { 282 | const controller = new AbortController() 283 | const signal = controller.signal 284 | controller.abort() 285 | const request = alice.request('ping', { signal, onCancel: () => new ErrorCustom() }) 286 | await assertThrow(request, ErrorCustom) 287 | } 288 | 289 | { 290 | const onCancelFn = spy() 291 | const controller = new AbortController() 292 | const signal = controller.signal 293 | controller.abort() 294 | const request = alice.request('ping', { signal, onCancel: onCancelFn }) 295 | await assertThrow(request, NM_ERR_CANCEL) 296 | assert.ok(onCancelFn.called) 297 | } 298 | 299 | { 300 | const controller = new AbortController() 301 | const signal = controller.signal 302 | const request = alice.request('ping', { signal }) 303 | setTimeout(() => controller.abort(), 50) 304 | await assertThrow(request, NM_ERR_CANCEL) 305 | } 306 | 307 | { 308 | const controller = new AbortController() 309 | const signal = controller.signal 310 | const request = alice.request('ping', { signal, timeout: 150 }) 311 | await new Promise(resolve => setTimeout(resolve, 110)) 312 | controller.abort() 313 | assert.is(await request, 'pong') 314 | } 315 | }) 316 | 317 | test('processIncomingMessage', async () => { 318 | const messageContext = [] 319 | 320 | const alice = new Nanomessage({ 321 | send: (data, info) => { 322 | messageContext.push(info.context) 323 | bob.processIncomingMessage(data, { 324 | context: info.context 325 | }) 326 | }, 327 | onMessage: (_, info) => { 328 | messageContext.push(info.context) 329 | } 330 | }) 331 | 332 | const bob = new Nanomessage({ 333 | send: (data) => { 334 | alice.processIncomingMessage(data) 335 | } 336 | }) 337 | 338 | await alice.open() 339 | await bob.open() 340 | 341 | await alice.request('ping', { context: 'randomData' }) 342 | assert.equal(messageContext, ['randomData']) 343 | }) 344 | 345 | test('valueEncoding', async () => { 346 | const onEncode = spy() 347 | const onDecode = spy() 348 | 349 | const valueEncoding = { 350 | encode (obj) { 351 | onEncode() 352 | return JSON.stringify(obj) 353 | }, 354 | decode (str) { 355 | onDecode() 356 | return JSON.parse(str) 357 | } 358 | } 359 | 360 | const alice = new Nanomessage({ 361 | valueEncoding, 362 | send: (data, info) => { 363 | bob.processIncomingMessage(data) 364 | }, 365 | onMessage: (data, info) => { 366 | assert.is(data, 'ping') 367 | return 'pong' 368 | } 369 | }) 370 | 371 | const bob = new Nanomessage({ 372 | valueEncoding, 373 | send: (data) => { 374 | alice.processIncomingMessage(data) 375 | } 376 | }) 377 | 378 | await alice.open() 379 | await bob.open() 380 | 381 | const res = await bob.request('ping') 382 | assert.is(res, 'pong') 383 | 384 | assert.is(onEncode.callCount, 2) 385 | assert.is(onDecode.callCount, 2) 386 | }) 387 | 388 | test('create a request over a closed resource', async () => { 389 | const [alice] = create() 390 | await alice.close() 391 | // do nothing with incoming messages 392 | await alice.processIncomingMessage() 393 | await assertThrow(alice.request('test'), NM_ERR_CLOSE) 394 | }) 395 | 396 | test('error-message', async () => { 397 | const [alice, bob] = create({ onMessage: () => { throw new Error('oh no') } }) 398 | 399 | const result = Nanomessage.once(alice, 'error-message') 400 | await bob.send('test') 401 | const [error] = await result 402 | assert.is(error.code, NM_ERR_MESSAGE.code) 403 | await assertThrow(bob.request('test'), NM_ERR_REMOTE_RESPONSE) 404 | }) 405 | 406 | test('nanoerror error-message', async () => { 407 | const Err = createError('ERR', 'oh no') 408 | const [alice, bob] = create({ onMessage: () => { throw new Err() } }) 409 | 410 | const result = Nanomessage.once(alice, 'error-message') 411 | await bob.send('test') 412 | const [error] = await result 413 | assert.instance(error, Err) 414 | 415 | await assertThrow(bob.request('test'), Err) 416 | }) 417 | 418 | test('send-error', async () => { 419 | const [alice, bob] = create({ 420 | send: () => { 421 | throw new Error('send error') 422 | } 423 | }) 424 | 425 | const result = Nanomessage.once(alice, 'error-message') 426 | await assertThrow(bob.request('test', { timeout: 100 }), NM_ERR_TIMEOUT) 427 | const [error] = await result 428 | assert.is(error.code, NM_ERR_MESSAGE.code) 429 | }) 430 | 431 | test('send not implemented', async () => { 432 | const [alice] = create({ 433 | send: undefined 434 | }) 435 | 436 | await assertThrow(alice.request('test'), Error) 437 | }) 438 | 439 | test('open option', async () => { 440 | const openFn = spy() 441 | 442 | const [alice] = create({ 443 | open: openFn 444 | }) 445 | 446 | await alice.open() 447 | assert.ok(openFn.called) 448 | 449 | const bob = new Nanomessage() 450 | await assertThrow(bob.send('data'), NM_ERR_NOT_OPEN) 451 | }) 452 | 453 | test.run() 454 | --------------------------------------------------------------------------------