├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ ├── codeql-analysis.yml │ ├── dependency-review.yml │ ├── lint.yml │ ├── nodejs.yml │ └── types.yml ├── .gitignore ├── .husky └── pre-push ├── .npmrc ├── LICENSE ├── README.md ├── declaration.tsconfig.json ├── index.js ├── lib └── client.js ├── package.json ├── renovate.json ├── sample.env ├── test ├── .eslintrc ├── db-utils.js └── integration │ └── main.spec.js └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | indent_style = space 7 | indent_size = 2 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /coverage/**/* 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@voxpelli", 3 | "root": true 4 | } 5 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | schedule: 9 | - cron: '15 0 * * 5' 10 | 11 | permissions: 12 | actions: read 13 | contents: read 14 | security-events: write 15 | 16 | jobs: 17 | analyze: 18 | uses: voxpelli/ghatemplates/.github/workflows/codeql-analysis.yml@main 19 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | name: 'Dependency Review' 2 | 3 | on: [pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | dependency-review: 10 | uses: voxpelli/ghatemplates/.github/workflows/dependency-review.yml@main 11 | 12 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Linting 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - '*' 9 | pull_request: 10 | branches: 11 | - main 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | lint: 18 | uses: voxpelli/ghatemplates/.github/workflows/lint.yml@main 19 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - '*' 9 | pull_request: 10 | branches: 11 | - main 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | test: 18 | uses: voxpelli/ghatemplates/.github/workflows/test-pg.yml@main 19 | with: 20 | node-versions: '16,18,20' 21 | os: 'ubuntu-latest' 22 | pg-versions: '9.4,12,13' 23 | -------------------------------------------------------------------------------- /.github/workflows/types.yml: -------------------------------------------------------------------------------- 1 | name: Type Checks 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - '*' 9 | pull_request: 10 | branches: 11 | - main 12 | schedule: 13 | - cron: '14 5 * * 1,3,5' 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | type-check: 20 | uses: voxpelli/ghatemplates/.github/workflows/type-check.yml@main 21 | with: 22 | ts-versions: ${{ github.event.schedule && 'next' || '5.0,next' }} 23 | ts-libs: 'es2020;esnext' 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Basic ones 2 | /coverage 3 | /docs 4 | /node_modules 5 | /.env 6 | /.nyc_output 7 | 8 | # We're a library, so please, no lock files 9 | /package-lock.json 10 | /yarn.lock 11 | 12 | # Generated types 13 | *.d.ts 14 | *.d.ts.map 15 | 16 | # Library specific ones 17 | .env 18 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm test 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Pelle Wessman 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 | # PG PubSub 2 | 3 | A Publish/Subscribe implementation on top of [PostgreSQL NOTIFY/LISTEN](https://www.postgresql.org/docs/current/sql-notify.html) 4 | 5 | [![npm version](https://img.shields.io/npm/v/pg-pubsub.svg?style=flat)](https://www.npmjs.com/package/pg-pubsub) 6 | [![npm downloads](https://img.shields.io/npm/dm/pg-pubsub.svg?style=flat)](https://www.npmjs.com/package/pg-pubsub) 7 | [![Module type: CJS](https://img.shields.io/badge/module%20type-cjs-brightgreen)](https://github.com/voxpelli/badges-cjs-esm) 8 | [![Types in JS](https://img.shields.io/badge/types_in_js-yes-brightgreen)](https://github.com/voxpelli/types-in-js) 9 | [![js-semistandard-style](https://img.shields.io/badge/code%20style-semistandard-brightgreen.svg)](https://github.com/voxpelli/eslint-config) 10 | [![Follow @voxpelli@mastodon.social](https://img.shields.io/mastodon/follow/109247025527949675?domain=https%3A%2F%2Fmastodon.social&style=social)](https://mastodon.social/@voxpelli) 11 | 12 | ## Installation 13 | 14 | ```bash 15 | npm install pg-pubsub --save 16 | ``` 17 | 18 | ## Requirements 19 | 20 | * Postgres >= 9.4 21 | 22 | ## Usage 23 | 24 | ```js 25 | const PGPubsub = require('pg-pubsub'); 26 | const pubsubInstance = new PGPubsub(uri[, options]); 27 | ``` 28 | 29 | ### Options 30 | 31 | ```js 32 | { 33 | [log]: Function // default: silent when NODE_ENV=production, otherwise defaults to console.log(...) 34 | } 35 | ``` 36 | 37 | ### Methods 38 | 39 | * **addChannel(channelName[, eventListener])** – starts listening on a channel and optionally adds an event listener for that event. As `PGPubsub` inherits from `EventEmitter` one can also add it oneself. Returns a `Promise` that resolves when the listening has started. 40 | * **removeChannel(channelName[, eventListener])** – either removes all event listeners and stops listeneing on the channel or removes the specified event listener and stops listening on the channel if that was the last listener attached. 41 | * **publish(channelName, data)** – publishes the specified data JSON-encoded to the specified channel. It may be better to do this by sending the `NOTIFY channelName, '{"hello":"world"}'` query yourself using your ordinary Postgres pool, rather than relying on the single connection of this module. Returns a `Promise` that will become rejected or resolved depending on the success of the Postgres call. 42 | * **close(): Promise** – closes down the database connection and removes all listeners. Useful for graceful shutdowns. 43 | * All [EventEmitter methods](http://nodejs.org/api/events.html#events_class_events_eventemitter) are inherited from `EventEmitter` 44 | 45 | ### Examples 46 | 47 | #### Simple 48 | 49 | ```javascript 50 | const pubsubInstance = new PGPubsub('postgres://username@localhost/database'); 51 | 52 | await pubsubInstance.addChannel('channelName', function (channelPayload) { 53 | // Process the payload – if it was JSON that JSON has been parsed into an object for you 54 | }); 55 | 56 | await pubsubInstance.publish('channelName', { hello: "world" }); 57 | ``` 58 | 59 | The above sends `NOTIFY channelName, '{"hello":"world"}'` to PostgreSQL, which will trigger the above listener with the parsed JSON in `channelPayload`. 60 | 61 | #### Advanced 62 | 63 | ```javascript 64 | const pubsubInstance = new PGPubsub('postgres://username@localhost/database'); 65 | 66 | await pubsubInstance.addChannel('channelName'); 67 | 68 | // pubsubInstance is a full EventEmitter object that sends events on channel names 69 | pubsubInstance.once('channelName', channelPayload => { 70 | // Process the payload 71 | }); 72 | ``` 73 | 74 | ## Description 75 | 76 | Creating a `PGPubsub` instance will not do much up front. It will prepare itself to start a Postgres connection once the first channel is added and then it will keep a connection open until its shut down, reconnecting it if it gets lost, so that it can constantly listen for new notifications. 77 | 78 | ## Lint / Test 79 | 80 | - setup a postgres database to run the integration tests 81 | - the easist way to do this is via docker, `docker run -it -p 5432:5432 -e POSTGRES_DB=pgpubsub_test postgres` 82 | - `npm test` 83 | 84 | For an all-in-one command, try: 85 | ```sh 86 | # fire up a new DB container, run tests against it, and clean it up! 87 | docker rm -f pgpubsub_test || true && \ 88 | docker run -itd -p 5432:5432 -e POSTGRES_DB=pgpubsub_test --name pgpubsub_test postgres && \ 89 | npm test && \ 90 | docker rm -f pgpubsub_test 91 | ``` 92 | 93 | -------------------------------------------------------------------------------- /declaration.tsconfig.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "extends": "./tsconfig", 4 | "exclude": [ 5 | "test/**/*.js" 6 | ], 7 | "compilerOptions": { 8 | "declaration": true, 9 | "declarationMap": true, 10 | "noEmit": false, 11 | "emitDeclarationOnly": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable promise/prefer-await-to-then */ 2 | // @ts-check 3 | /// 4 | /// 5 | 6 | 'use strict'; 7 | 8 | const EventEmitter = require('node:events'); 9 | 10 | const pgFormat = require('pg-format'); 11 | const { ErrorWithCause } = require('pony-cause'); 12 | 13 | const { pgClientRetry } = require('./lib/client'); 14 | 15 | // TODO: Move to an async generator approach rather than EventEmitter 16 | 17 | /** @typedef {(payload: any) => void} PGPubsubCallback */ 18 | 19 | class PGPubsub extends EventEmitter { 20 | /** @type {string[]} */ 21 | #channels = []; 22 | 23 | /** @type {import('promised-retry')} */ 24 | #retry; 25 | 26 | /** 27 | * @param {string | import('pg').ClientConfig} [conString] 28 | * @param {{ log?: typeof console.log, retryLimit?: number }} options 29 | */ 30 | // eslint-disable-next-line n/no-process-env 31 | constructor (conString = process.env['DATABASE_URL'], { log, retryLimit } = {}) { 32 | super(); 33 | 34 | this.setMaxListeners(0); 35 | 36 | this.#retry = pgClientRetry({ 37 | clientOptions: typeof conString === 'object' ? conString : { connectionString: conString }, 38 | retryLimit, 39 | log, 40 | shouldReconnect: () => this.#channels.length !== 0, 41 | successCallback: client => { 42 | client.on('notification', msg => this.#processNotification(msg)); 43 | 44 | Promise.all(this.#channels.map(channel => client.query('LISTEN "' + channel + '"'))) 45 | .catch(/** @param {unknown} err */err => { 46 | this.emit( 47 | 'error', 48 | new ErrorWithCause('Failed to set up channels on new connection', { cause: err }) 49 | ); 50 | }); 51 | 52 | return client; 53 | }, 54 | }); 55 | } 56 | 57 | /** 58 | * @protected 59 | * @param {boolean} [noNewConnections] 60 | * @returns {Promise} 61 | */ 62 | async _getDB (noNewConnections) { 63 | return this.#retry.try(!noNewConnections) 64 | .catch(/** @param {unknown} err */err => { 65 | throw new ErrorWithCause('Failed to establish database connection', { cause: err }); 66 | }); 67 | } 68 | 69 | /** 70 | * @param {import('pg').Notification} msg 71 | * @returns {void} 72 | */ 73 | #processNotification (msg) { 74 | let payload = msg.payload || ''; 75 | 76 | // If the payload is valid JSON, then replace it with such 77 | try { payload = JSON.parse(payload); } catch {} 78 | 79 | this.emit(msg.channel, payload); 80 | } 81 | 82 | /** 83 | * @param {string} channel 84 | * @param {PGPubsubCallback} [callback] 85 | * @returns {Promise} 86 | */ 87 | async addChannel (channel, callback) { 88 | if (!this.#channels.includes(channel)) { 89 | this.#channels.push(channel); 90 | 91 | // TODO: Can't this possibly result in both the try() method and this method adding a LISTEN for it? 92 | try { 93 | const db = await this._getDB(); 94 | await db.query('LISTEN "' + channel + '"'); 95 | } catch (err) { 96 | throw new ErrorWithCause('Failed to listen to channel', { cause: err }); 97 | } 98 | } 99 | 100 | if (callback) { 101 | this.on(channel, callback); 102 | } 103 | } 104 | 105 | /** 106 | * @param {string} channel 107 | * @param {PGPubsubCallback} [callback] 108 | * @returns {this} 109 | */ 110 | removeChannel (channel, callback) { 111 | const pos = this.#channels.indexOf(channel); 112 | 113 | if (pos === -1) { 114 | return this; 115 | } 116 | 117 | if (callback) { 118 | this.removeListener(channel, callback); 119 | } else { 120 | this.removeAllListeners(channel); 121 | } 122 | 123 | if (this.listeners(channel).length === 0) { 124 | this.#channels.splice(pos, 1); 125 | this._getDB(true) 126 | .then(db => db.query('UNLISTEN "' + channel + '"')) 127 | .catch(/** @param {unknown} err */err => { 128 | this.emit( 129 | 'error', 130 | new ErrorWithCause('Failed to stop listening to channel', { cause: err }) 131 | ); 132 | }); 133 | } 134 | 135 | return this; 136 | } 137 | 138 | /** 139 | * @param {string} channel 140 | * @param {any} [data] 141 | * @returns {Promise} 142 | */ 143 | async publish (channel, data) { 144 | const payload = data ? ', ' + pgFormat.literal(JSON.stringify(data)) : ''; 145 | 146 | try { 147 | const db = await this._getDB(); 148 | await db.query(`NOTIFY "${channel}"${payload}`); 149 | } catch (err) { 150 | throw new ErrorWithCause('Failed to publish to channel', { cause: err }); 151 | } 152 | } 153 | 154 | /** @returns {Promise} */ 155 | async close () { 156 | this.removeAllListeners(); 157 | this.#channels = []; 158 | return this.#retry.end(); 159 | } 160 | 161 | reset () { 162 | return this.#retry.reset(); 163 | } 164 | } 165 | 166 | module.exports = PGPubsub; 167 | -------------------------------------------------------------------------------- /lib/client.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | /// 3 | /// 4 | 5 | 'use strict'; 6 | 7 | const Retry = require('promised-retry'); 8 | 9 | const { Client } = require('pg'); 10 | 11 | /** @returns {typeof console.log} */ 12 | const getDefaultLog = () => 13 | // eslint-disable-next-line n/no-process-env 14 | process.env['NODE_ENV'] === 'production' 15 | ? () => {} 16 | // eslint-disable-next-line no-console 17 | : console.log.bind(console); 18 | 19 | /** 20 | * @typedef PgClientRetryOptions 21 | * @property {string|import('pg').ClientConfig} clientOptions 22 | * @property {(typeof console.log)|undefined} [log] 23 | * @property {number|undefined} [retryLimit] 24 | * @property {(client: import('pg').Client) => Promise|import('pg').Client} [successCallback] 25 | * @property {() => boolean} [shouldReconnect] 26 | */ 27 | 28 | /** 29 | * @param {PgClientRetryOptions} options 30 | * @returns {import('promised-retry')} 31 | */ 32 | const pgClientRetry = (options) => { 33 | const { 34 | clientOptions, 35 | log = getDefaultLog(), 36 | retryLimit, 37 | shouldReconnect, 38 | successCallback, 39 | } = options; 40 | 41 | const retry = new Retry({ 42 | // TODO: Improve types for this in promised-retry 43 | 'try': async () => { 44 | const client = new Client(clientOptions); 45 | 46 | // TODO: Add client.on('end') ? 47 | // If the connection fail after we have established it, then we need to reset the state of our retry mechanism and restart from scratch. 48 | client.on('error', () => { 49 | retry.reset(); 50 | if (shouldReconnect && shouldReconnect()) retry.try(); 51 | client.end(err => { 52 | log('Received error when disconnecting from database in error callback: ' + (err && err.message)); 53 | }); 54 | }); 55 | 56 | // Do the connect 57 | await client.connect(); 58 | 59 | return client; 60 | }, 61 | // TODO: Improve types for this in promised-retry, what should actually be returned? 62 | success: 63 | /** 64 | * @param {import('pg').Client} client 65 | * @returns {Promise} 66 | */ 67 | async client => successCallback ? successCallback(client) : client, 68 | // TODO: Improve types for this in promised-retry 69 | end: 70 | /** 71 | * @param {import('pg').Client} [client] 72 | * @returns {Promise} 73 | */ 74 | async client => client ? client.end() : undefined, 75 | name: 'pgClientRetry', 76 | retryLimit, 77 | log, 78 | }); 79 | 80 | return retry; 81 | }; 82 | 83 | module.exports = { 84 | pgClientRetry, 85 | }; 86 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pg-pubsub", 3 | "version": "0.8.1", 4 | "description": "A Publish/Subscribe implementation on top of PostgreSQL NOTIFY/LISTEN", 5 | "homepage": "http://github.com/voxpelli/node-pg-pubsub", 6 | "repository": { 7 | "type": "git", 8 | "url": "git://github.com/voxpelli/node-pg-pubsub.git" 9 | }, 10 | "author": { 11 | "name": "Pelle Wessman", 12 | "email": "pelle@kodfabrik.se", 13 | "url": "http://kodfabrik.se/" 14 | }, 15 | "license": "MIT", 16 | "engines": { 17 | "node": ">=16.0.0" 18 | }, 19 | "main": "index.js", 20 | "types": "index.d.ts", 21 | "files": [ 22 | "index.js", 23 | "index.d.ts", 24 | "index.d.ts.map", 25 | "lib/*.js", 26 | "lib/*.d.ts", 27 | "lib/*.d.ts.map" 28 | ], 29 | "scripts": { 30 | "build:0": "run-s clean", 31 | "build:1-declaration": "tsc -p declaration.tsconfig.json", 32 | "build:2-add-ignores": "ts-ignore-import '**/*.d.ts'", 33 | "build": "run-s build:*", 34 | "check:dependency-check": "dependency-check *.js 'test/**/*.js' --no-dev", 35 | "check:installed-check": "installed-check -i eslint-plugin-jsdoc", 36 | "check:lint": "eslint --report-unused-disable-directives .", 37 | "check:tsc": "tsc", 38 | "check:type-coverage": "type-coverage --detail --strict --at-least 98 --ignore-files 'test/**/*'", 39 | "check": "run-s clean && run-p check:*", 40 | "clean:declarations": "rm -rf $(find . -maxdepth 2 -type f -name '*.d.ts*')", 41 | "clean": "run-p clean:*", 42 | "prepublishOnly": "run-s build", 43 | "test:mocha": "cross-env NODE_ENV=test c8 --reporter=lcov --reporter text mocha test/**/*.spec.js", 44 | "test-ci": "run-s test:*", 45 | "test": "run-s check test:*", 46 | "prepare": "husky install" 47 | }, 48 | "dependencies": { 49 | "pg": "^8.7.3", 50 | "pg-format": "^1.0.2", 51 | "pony-cause": "^2.1.10", 52 | "promised-retry": "^0.5.0" 53 | }, 54 | "devDependencies": { 55 | "@hdsydsvenskan/ts-ignore-import": "^2.0.0", 56 | "@types/chai": "^4.3.5", 57 | "@types/chai-as-promised": "^7.1.5", 58 | "@types/mocha": "^10.0.1", 59 | "@types/node": "^16.18.28", 60 | "@types/pg": "^8.6.6", 61 | "@types/pg-format": "^1.0.2", 62 | "@voxpelli/eslint-config": "^16.0.7", 63 | "@voxpelli/tsconfig": "^7.0.0", 64 | "c8": "^7.13.0", 65 | "chai": "^4.3.7", 66 | "chai-as-promised": "^7.1.1", 67 | "cross-env": "^7.0.3", 68 | "dependency-check": "^5.0.0-7", 69 | "dotenv": "^16.0.3", 70 | "eslint": "^8.40.0", 71 | "eslint-config-standard": "^17.0.0", 72 | "eslint-plugin-es": "^4.1.0", 73 | "eslint-plugin-import": "^2.27.5", 74 | "eslint-plugin-jsdoc": "^44.2.2", 75 | "eslint-plugin-mocha": "^10.1.0", 76 | "eslint-plugin-n": "^15.7.0", 77 | "eslint-plugin-promise": "^6.1.1", 78 | "eslint-plugin-security": "^1.7.1", 79 | "eslint-plugin-sort-destructure-keys": "^1.5.0", 80 | "eslint-plugin-unicorn": "^47.0.0", 81 | "husky": "^8.0.3", 82 | "installed-check": "^7.0.0", 83 | "mocha": "^10.2.0", 84 | "npm-run-all2": "^6.0.5", 85 | "type-coverage": "^2.25.3", 86 | "typescript": "~5.0.4" 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "github>voxpelli/renovate-config" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /sample.env: -------------------------------------------------------------------------------- 1 | DATABASE_TEST_URL="postgres://postgres@localhost/pgpubsub_test" 2 | DATABASE_TEST_URL_INVALID_USER="postgres://foobar:pass@localhost/pgpubsub_test" 3 | DATABASE_TEST_URL_INVALID_PASSWORD="postgres://postgres:invalidpass@localhost/pgpubsub_test" 4 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | }, 5 | "rules": { 6 | "no-unused-expressions": 0, 7 | "node/no-unpublished-require": 0, 8 | "promise/prefer-await-to-then": 0 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /test/db-utils.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable n/no-process-env */ 2 | 3 | // @ts-check 4 | /// 5 | 6 | 'use strict'; 7 | 8 | const pathModule = require('node:path'); 9 | 10 | const dotEnvFile = process.env['DOTENV_FILE'] || pathModule.resolve(__dirname, './.env'); 11 | 12 | require('dotenv').config({ path: dotEnvFile }); 13 | 14 | const connectionDetails = process.env['DATABASE_TEST_URL'] || { 15 | database: process.env['PGDATABASE'] || 'pgpubsub_test', 16 | }; 17 | 18 | module.exports = { connectionDetails }; 19 | -------------------------------------------------------------------------------- /test/integration/main.spec.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | /// 3 | /// 4 | /// 5 | /// 6 | 7 | 'use strict'; 8 | 9 | const chai = require('chai'); 10 | const chaiAsPromised = require('chai-as-promised'); 11 | 12 | const { connectionDetails } = require('../db-utils'); 13 | const PGPubsub = require('../../'); 14 | 15 | chai.use(chaiAsPromised); 16 | chai.should(); 17 | 18 | // @ts-ignore 19 | // eslint-disable-next-line no-console 20 | process.on('unhandledRejection', err => { console.log('Unhandled Rejection:', err.stack); }); 21 | 22 | // eslint-disable-next-line n/no-process-env 23 | const conStringInvalidUser = process.env['DATABASE_TEST_URL_INVALID_USER'] || 'postgres://invalidUsername@localhost/pgpubsub_test'; 24 | // eslint-disable-next-line n/no-process-env 25 | const conStringInvalidPassword = process.env['DATABASE_TEST_URL_INVALID_PASSWORD'] || 'postgres://postgres:invalid@localhost/pgpubsub_test'; 26 | 27 | /** 28 | * @template T 29 | * @returns {[Promise, (value?: T | PromiseLike) => void, (err: Error) => void]} 30 | */ 31 | const resolveablePromise = () => { 32 | /** @type {(value?: T | PromiseLike) => void} */ 33 | let resolver; 34 | /** @type {(err: Error) => void} */ 35 | let rejecter; 36 | 37 | const resolveable = new Promise((resolve, reject) => { 38 | resolver = resolve; 39 | rejecter = reject; 40 | }); 41 | 42 | // @ts-ignore 43 | return [resolveable, resolver, rejecter]; 44 | }; 45 | 46 | describe('Pubsub', () => { 47 | /** @type {import('../../index')} */ 48 | let pubsubInstance; 49 | /** @type {import('pg').Client} */ 50 | let db; 51 | 52 | beforeEach(async () => { 53 | pubsubInstance = new PGPubsub(connectionDetails, { 54 | log: function (...params) { 55 | if (typeof arguments[0] !== 'string' || !arguments[0].startsWith('Success')) { 56 | // eslint-disable-next-line no-console 57 | console.log.call(this, ...params); 58 | } 59 | }, 60 | }); 61 | 62 | // @ts-ignore 63 | db = await pubsubInstance._getDB(); 64 | }); 65 | 66 | afterEach(() => pubsubInstance.close()); 67 | 68 | describe('init', function () { 69 | this.timeout(2000); 70 | 71 | it('should handle errenous database user', async () => { 72 | pubsubInstance.close(); 73 | pubsubInstance = new PGPubsub(conStringInvalidUser, { 74 | log: () => {}, 75 | retryLimit: 1, 76 | }); 77 | // @ts-ignore 78 | return pubsubInstance._getDB() 79 | .should.be.rejectedWith(/Failed to establish database connection/); 80 | }); 81 | 82 | // TODO: Fix, doesn't work on Travis right now 83 | it.skip('should handle errenous database password', async () => { 84 | pubsubInstance.close(); 85 | pubsubInstance = new PGPubsub(conStringInvalidPassword, { 86 | log: () => {}, 87 | retryLimit: 1, 88 | }); 89 | // @ts-ignore 90 | return pubsubInstance._getDB() 91 | .should.be.rejectedWith(/Failed to establish database connection/); 92 | }); 93 | }); 94 | 95 | describe('receive', function () { 96 | it('should receive a notification', async () => { 97 | const [result, resolve] = resolveablePromise(); 98 | 99 | await pubsubInstance.addChannel('foobar', (channelPayload) => { 100 | channelPayload.should.deep.equal({ abc: 123 }); 101 | resolve(); 102 | }); 103 | 104 | await db.query('NOTIFY foobar, \'{"abc":123}\''); 105 | 106 | return result; 107 | }); 108 | 109 | it('should handle non-JSON notifications', async () => { 110 | const [result, resolve] = resolveablePromise(); 111 | 112 | await pubsubInstance.addChannel('foobar', channelPayload => { 113 | channelPayload.should.equal('barfoo'); 114 | resolve(); 115 | }); 116 | await db.query('NOTIFY foobar, \'barfoo\''); 117 | 118 | return result; 119 | }); 120 | 121 | it('should only receive notifications from correct channel', async () => { 122 | const [result1, resolve1] = resolveablePromise(); 123 | const [result2, resolve2] = resolveablePromise(); 124 | 125 | await pubsubInstance.addChannel('foo', channelPayload => { 126 | channelPayload.should.deep.equal({ abc: 123 }); 127 | resolve1(); 128 | }); 129 | 130 | await pubsubInstance.addChannel('bar', channelPayload => { 131 | channelPayload.should.deep.equal({ xyz: 789 }); 132 | resolve2(); 133 | }); 134 | 135 | await Promise.all([ 136 | db.query('NOTIFY def, \'{"ghi":456}\''), 137 | db.query('NOTIFY foo, \'{"abc":123}\''), 138 | db.query('NOTIFY bar, \'{"xyz":789}\''), 139 | ]); 140 | 141 | await Promise.all([ 142 | result1, 143 | result2, 144 | ]); 145 | }); 146 | 147 | it('should handle non-alphanumeric channel names', async () => { 148 | const [result, resolve] = resolveablePromise(); 149 | 150 | await pubsubInstance.addChannel('97a38cd1-d332-4240-93e4-1ff436a7da2a', function (channelPayload) { 151 | channelPayload.should.deep.equal({ 'non-alpha': true }); 152 | resolve(); 153 | }); 154 | 155 | await db.query('NOTIFY "97a38cd1-d332-4240-93e4-1ff436a7da2a", \'{"non-alpha":true}\''); 156 | 157 | return result; 158 | }); 159 | 160 | it('should stop listening when channel is removed', async () => { 161 | const [result, resolve] = resolveablePromise(); 162 | 163 | await pubsubInstance.addChannel('foo', function () { 164 | throw new Error('This channel should have been removed and should not receive any items'); 165 | }); 166 | 167 | await pubsubInstance.addChannel('foo', function () { 168 | throw new Error('This channel should have been removed and should not receive any items'); 169 | }); 170 | 171 | await pubsubInstance.addChannel('bar', function () { 172 | resolve(); 173 | }); 174 | 175 | pubsubInstance.removeChannel('foo'); 176 | 177 | await db.query('NOTIFY foo, \'{"abc":123}\''); 178 | await db.query('NOTIFY bar, \'{"xyz":789}\''); 179 | 180 | return result; 181 | }); 182 | 183 | it('should allow multiple listener for same channel', async () => { 184 | const [result, resolve] = resolveablePromise(); 185 | 186 | let first = false; 187 | 188 | await pubsubInstance.addChannel('foobar', function () { 189 | first = true; 190 | }); 191 | await pubsubInstance.addChannel('foobar', function () { 192 | first.should.be.ok; 193 | resolve(); 194 | }); 195 | 196 | await db.query('NOTIFY foobar, \'{"abc":123}\''); 197 | 198 | return result; 199 | }); 200 | 201 | it('should be able to remove specific listener', async () => { 202 | const [result, resolve] = resolveablePromise(); 203 | 204 | let second = false; 205 | 206 | // eslint-disable-next-line unicorn/consistent-function-scoping 207 | const listener = function () { 208 | throw new Error('This channel should have been removed and should not receive any items'); 209 | }; 210 | 211 | await pubsubInstance.addChannel('foobar', listener); 212 | 213 | await pubsubInstance.addChannel('foobar', function () { 214 | if (second) { 215 | resolve(); 216 | } else { 217 | second = true; 218 | } 219 | }); 220 | 221 | pubsubInstance.removeChannel('foobar', listener); 222 | 223 | await db.query('NOTIFY foobar, \'{"abc":123}\''); 224 | await db.query('NOTIFY foobar, \'{"abc":123}\''); 225 | 226 | return result; 227 | }); 228 | 229 | it('should support EventEmitter methods for listening', async () => { 230 | const [result, resolve] = resolveablePromise(); 231 | 232 | await pubsubInstance.addChannel('foobar'); 233 | 234 | pubsubInstance.on('foobar', function () { 235 | resolve(); 236 | }); 237 | 238 | await db.query('NOTIFY foobar, \'{"abc":123}\''); 239 | 240 | return result; 241 | }); 242 | 243 | it('should support recovery after reconnect', async () => { 244 | const [result, resolve] = resolveablePromise(); 245 | 246 | await pubsubInstance.addChannel('foobar', function () { 247 | resolve(); 248 | }); 249 | 250 | setImmediate(() => { 251 | db.end(); 252 | pubsubInstance.reset(); 253 | 254 | // @ts-ignore 255 | // eslint-disable-next-line promise/always-return, promise/catch-or-return 256 | pubsubInstance._getDB().then(async db => { 257 | await db.query('NOTIFY foobar, \'{"abc":123}\''); 258 | }); 259 | }); 260 | 261 | return result; 262 | }); 263 | }); 264 | 265 | describe('publish', function () { 266 | it('should publish a notification', async () => { 267 | const [result, resolve] = resolveablePromise(); 268 | 269 | const data = { abc: 123 }; 270 | 271 | await pubsubInstance.addChannel('foobar', function (channelPayload) { 272 | channelPayload.should.deep.equal(data); 273 | resolve(); 274 | }); 275 | 276 | await pubsubInstance.publish('foobar', data); 277 | 278 | return result; 279 | }); 280 | 281 | it('should not be vulnerable to SQL injection', async () => { 282 | const [result, resolve] = resolveablePromise(); 283 | 284 | const data = { abc: '\'"; AND DO SOMETHING BAD' }; 285 | 286 | await pubsubInstance.addChannel('foobar', function (channelPayload) { 287 | channelPayload.should.deep.equal(data); 288 | resolve(); 289 | }); 290 | 291 | await pubsubInstance.publish('foobar', data); 292 | 293 | return result; 294 | }); 295 | 296 | it('should gracefully handle too large payloads', async () => { 297 | const data = Array.from({ length: 10000 }); 298 | data.fill('a'); 299 | return pubsubInstance.publish('foobar', data).should.be.rejectedWith(Error); 300 | }); 301 | }); 302 | }); 303 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@voxpelli/tsconfig/node16.json", 3 | "files": [ 4 | "index.js", 5 | ], 6 | "include": [ 7 | "lib/**/*.js", 8 | "test/**/*.js", 9 | ] 10 | } 11 | --------------------------------------------------------------------------------