├── .github ├── renovate.json5 └── workflows │ └── checks.yml ├── .gitignore ├── .yarnrc.yml ├── LICENSE.md ├── README.md ├── bin └── test.ts ├── eslint.config.js ├── index.ts ├── package.json ├── src ├── storage.ts ├── stream.ts ├── stream_manager.ts ├── transmit.ts ├── transport_message_type.ts ├── types │ └── main.ts └── utils.ts ├── tests ├── fixtures │ ├── stream.ts │ └── transmit.ts ├── mocks │ ├── sink.ts │ └── socket.ts ├── storage.spec.ts ├── stream.spec.ts ├── stream_manager.spec.ts ├── transmit.spec.ts └── utils.spec.ts ├── transports.ts ├── tsconfig.json └── yarn.lock /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | $schema: "https://docs.renovatebot.com/renovate-schema.json", 3 | extends: ["config:recommended", "schedule:weekly", "group:allNonMajor"], 4 | labels: ["dependencies"], 5 | rangeStrategy: "bump", 6 | packageRules: [ 7 | { 8 | matchDepTypes: ["peerDependencies"], 9 | enabled: false, 10 | }, 11 | ], 12 | ignoreDeps: [ 13 | // manually bumping 14 | "node", 15 | "@types/node" 16 | ], 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: checks 2 | on: 3 | - push 4 | - pull_request 5 | 6 | jobs: 7 | test: 8 | uses: boringnode/.github/.github/workflows/test.yml@main 9 | 10 | lint: 11 | uses: boringnode/.github/.github/workflows/lint.yml@main 12 | 13 | typecheck: 14 | uses: boringnode/.github/.github/workflows/typecheck.yml@main 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Build 2 | build 3 | 4 | # Node & Dependencies 5 | node_modules 6 | coverage 7 | 8 | # Build tools specific 9 | .yarn/* 10 | !.yarn/patches 11 | !.yarn/plugins 12 | !.yarn/releases 13 | !.yarn/sdks 14 | !.yarn/versions 15 | npm-debug.log 16 | yarn-error.log 17 | 18 | # Editors specific 19 | .fleet 20 | .idea 21 | .vscode 22 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | Copyright 2024 Romain Lanz, contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | @boringnode/transmit 3 |
4 | 5 |
6 | 7 | [![typescript-image]][typescript-url] 8 | [![gh-workflow-image]][gh-workflow-url] 9 | [![npm-image]][npm-url] 10 | [![npm-download-image]][npm-download-url] 11 | [![license-image]][license-url] 12 | 13 |
14 | 15 | `@boringnode/transmit` is a framework-agnostic opinionated library to manage [Server-Sent Events (SSE)](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) in Node.js. 16 | 17 | Here are a few things you should know before using this module. 18 | 19 |

20 | 👉 Unidirectional Communication: The data transmission occurs only from server to client, not the other way around.
21 | 👉 Textual Data Only: SSE only supports the transmission of textual data, binary data cannot be sent.
22 | 👉 HTTP Protocol: The underlying protocol used is the regular HTTP, not any special or proprietary protocol.
23 |

24 | 25 | ## Installation 26 | 27 | ```sh 28 | npm install @boringnode/transmit 29 | ``` 30 | 31 | ## Usage 32 | 33 | This module is designed to be used with any HTTP server framework. If you wish to write an adapter for a specific framework, please refer to the [Adapters](#adapters) section for examples. 34 | 35 | ### Broadcasting Data 36 | 37 | Once the connection is established, you can send data to the client using the `transmit.broadcast` method. 38 | 39 | ```ts 40 | // Given the "transmit" instance from the adapter 41 | transmit.broadcast('global', { message: 'Hello' }) 42 | transmit.broadcast('chats/1/messages', { message: 'Hello' }) 43 | transmit.broadcast('users/1', { message: 'Hello' }) 44 | ``` 45 | 46 | ### Authorization 47 | 48 | You can authorize the client to subscribe to a specific channel by using the `authorize` function. In the following example, we are using the [AdonisJS Framework](https://adonisjs.com/). 49 | 50 | ```ts 51 | import transmit from '@adonisjs/transmit/services/main' 52 | import Chat from '#models/chat' 53 | import type { HttpContext } from '@adonisjs/core/http' 54 | 55 | transmit.authorize<{ id: string }>('users/:id', (ctx: HttpContext, { id }) => { 56 | return ctx.auth.user?.id === +id 57 | }) 58 | 59 | transmit.authorize<{ id: string }>('chats/:id/messages', async (ctx: HttpContext, { id }) => { 60 | const chat = await Chat.findOrFail(+id) 61 | 62 | return ctx.bouncer.allows('accessChat', chat) 63 | }) 64 | ``` 65 | 66 | ### Syncing across multiple servers or instances 67 | 68 | By default, broadcasting events works only within the context of an HTTP request. However, you can broadcast events from the background using the transmit service if you register a transport in your configuration. 69 | 70 | The transport layer is responsible for syncing events across multiple servers or instances. It works by broadcasting any events (like broadcasted events, subscriptions, and un-subscriptions) to all connected servers or instances using a Message Bus. 71 | 72 | The server or instance responsible for your client connection will receive the event and broadcast it to the client. 73 | 74 | ```ts 75 | import { Transmit } from '@boringnode/transmit' 76 | import { redis } from '@boringnode/transmit/transports' 77 | 78 | const transmit = new Transmit({ 79 | transport: { 80 | driver: redis({ 81 | host: process.env.REDIS_HOST, 82 | port: process.env.REDIS_PORT, 83 | password: process.env.REDIS_PASSWORD, 84 | keyPrefix: 'transmit', 85 | }) 86 | } 87 | }) 88 | ``` 89 | 90 | ## Transmit Client 91 | 92 | You can listen for events on the client-side using the `@adonisjs/transmit-client` package. The package provides a `Transmit` class. The client use the [EventSource API](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) by default to connect to the server. 93 | 94 | > [!NOTE] 95 | > Even if you are not working with AdonisJS, you can still use the `@adonisjs/transmit-client` package. 96 | 97 | ```ts 98 | import { Transmit } from '@adonisjs/transmit-client' 99 | 100 | export const transmit = new Transmit({ 101 | baseUrl: window.location.origin 102 | }) 103 | ``` 104 | 105 | ### Subscribing to Channels 106 | 107 | ```ts 108 | const subscription = transmit.subscription('chats/1/messages') 109 | await subscription.create() 110 | ``` 111 | 112 | ### Listening for Events 113 | 114 | ```ts 115 | subscription.onMessage((data) => { 116 | console.log(data) 117 | }) 118 | 119 | subscription.onMessageOnce(() => { 120 | console.log('I will be called only once') 121 | }) 122 | ``` 123 | 124 | ### Stop Listening for Events 125 | 126 | ```ts 127 | const stopListening = subscription.onMessage((data) => { 128 | console.log(data) 129 | }) 130 | 131 | // Stop listening 132 | stopListening() 133 | ``` 134 | 135 | ### Unsubscribing from Channels 136 | 137 | ```ts 138 | await subscription.delete() 139 | ``` 140 | 141 | ## Adapters 142 | 143 | Here are the available adapters for specific frameworks: 144 | 145 | - [AdonisJS](https://github.com/adonisjs/transmit) (Official) 146 | 147 | ### Writing an Adapter 148 | 149 | To write an adapter for a specific framework, you need to implement the following routes: 150 | 151 | - `GET /__transmit/events`: This route is used to establish a connection between the client and the server. It returns a stream that will be used to send data to the client. 152 | - `POST /__transmit/subscribe`: This route is used to subscribe the client to a specific channel. 153 | - `POST /__transmit/unsubscribe`: This route is used to unsubscribe the client from a specific channel. 154 | 155 | Here is an example of how you can implement the adapter for `fastify`: 156 | 157 | ```ts 158 | import Fastify from 'fastify' 159 | import { Transmit } from '@boringnode/transmit' 160 | 161 | const fastify = Fastify({ 162 | logger: true 163 | }) 164 | 165 | const transmit = new Transmit({ 166 | pingInterval: false, 167 | transport: null 168 | }) 169 | 170 | /** 171 | * Register the client connection and keep it alive. 172 | */ 173 | fastify.get('__transmit/events', (request, reply) => { 174 | const uid = request.query.uid as string 175 | 176 | if (!uid) { 177 | return reply.code(400).send({ error: 'Missing uid' }) 178 | } 179 | 180 | const stream = transmit.createStream({ 181 | uid, 182 | context: { request, reply } 183 | request: request.raw, 184 | response: reply.raw, 185 | injectResponseHeaders: reply.getHeaders() 186 | }) 187 | 188 | return reply.send(stream) 189 | }) 190 | 191 | /** 192 | * Subscribe the client to a specific channel. 193 | */ 194 | fastify.post('__transmit/subscribe', async (request, reply) => { 195 | const uid = request.body.uid as string 196 | const channel = request.body.channel as string 197 | 198 | const success = await transmit.subscribe({ 199 | uid, 200 | channel, 201 | context: { request, reply } 202 | }) 203 | 204 | if (!success) { 205 | return reply.code(400).send({ error: 'Unable to subscribe to the channel' }) 206 | } 207 | 208 | return reply.code(204).send() 209 | }) 210 | 211 | /** 212 | * Unsubscribe the client from a specific channel. 213 | */ 214 | fastify.post('__transmit/unsubscribe', async (request, reply) => { 215 | const uid = request.body.uid as string 216 | const channel = request.body.channel as string 217 | 218 | const success = await transmit.unsubscribe({ 219 | uid, 220 | channel, 221 | context: { request, reply } 222 | }) 223 | 224 | if (!success) { 225 | return reply.code(400).send({ error: 'Unable to unsubscribe to the channel' }) 226 | } 227 | 228 | return reply.code(204).send() 229 | }) 230 | 231 | fastify.listen({ port: 3000 }) 232 | ``` 233 | 234 | ## Avoiding GZip Interference 235 | 236 | When deploying applications that use `@boringnode/transmit`, it’s important to ensure that GZip compression does not interfere with the `text/event-stream` content type used by Server-Sent Events (SSE). Compression applied to `text/event-stream` can cause connection issues, leading to frequent disconnects or SSE failures. 237 | 238 | If your deployment uses a reverse proxy (such as Traefik or Nginx) or other middleware that applies GZip, ensure that compression is disabled for the `text/event-stream` content type. 239 | 240 | ### Example Configuration for Traefik 241 | 242 | ```plaintext 243 | traefik.http.middlewares.gzip.compress=true 244 | traefik.http.middlewares.gzip.compress.excludedcontenttypes=text/event-stream 245 | traefik.http.routers.my-router.middlewares=gzip 246 | ``` 247 | 248 | 249 | [gh-workflow-image]: https://img.shields.io/github/actions/workflow/status/boringnode/transmit/checks.yml?branch=0.x&style=for-the-badge 250 | [gh-workflow-url]: https://github.com/boringnode/transmit/actions/workflows/checks.yml 251 | [npm-image]: https://img.shields.io/npm/v/@boringnode/transmit.svg?style=for-the-badge&logo=npm 252 | [npm-url]: https://www.npmjs.com/package/@boringnode/transmit 253 | [npm-download-image]: https://img.shields.io/npm/dm/@boringnode/transmit?style=for-the-badge 254 | [npm-download-url]: https://www.npmjs.com/package/@boringnode/transmit 255 | [typescript-image]: https://img.shields.io/badge/Typescript-294E80.svg?style=for-the-badge&logo=typescript 256 | [typescript-url]: https://www.typescriptlang.org 257 | [license-image]: https://img.shields.io/npm/l/@boringnode/transmit?color=blueviolet&style=for-the-badge 258 | [license-url]: LICENSE.md 259 | -------------------------------------------------------------------------------- /bin/test.ts: -------------------------------------------------------------------------------- 1 | import { configure, processCLIArgs, run } from '@japa/runner' 2 | import { assert } from '@japa/assert' 3 | 4 | processCLIArgs(process.argv.splice(2)) 5 | configure({ 6 | files: ['tests/**/*.spec.ts'], 7 | plugins: [assert()], 8 | }) 9 | 10 | void run() 11 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { configPkg } from '@adonisjs/eslint-config' 2 | 3 | export default configPkg({ 4 | ignores: ['coverage'], 5 | }) 6 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @boringnode/transmit 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | export * from './src/transmit.js' 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@boringnode/transmit", 3 | "description": "A framework agnostic Server-Sent-Event library", 4 | "version": "0.2.2", 5 | "engines": { 6 | "node": ">=20.6" 7 | }, 8 | "main": "build/index.js", 9 | "type": "module", 10 | "files": [ 11 | "build" 12 | ], 13 | "exports": { 14 | ".": "./build/index.js", 15 | "./transports": "./build/transports.js", 16 | "./types": "./build/src/types/main.js" 17 | }, 18 | "scripts": { 19 | "build": "yarn compile", 20 | "clean": "del-cli build", 21 | "compile": "yarn clean && tsc", 22 | "format": "prettier --write .", 23 | "lint": "eslint .", 24 | "precompile": "yarn lint", 25 | "prepublishOnly": "yarn build", 26 | "pretest": "yarn lint", 27 | "quick:test": "yarn node --enable-source-maps --import=ts-node-maintained/register/esm bin/test.ts", 28 | "release": "release-it", 29 | "test": "c8 yarn quick:test", 30 | "typecheck": "tsc --noEmit", 31 | "version": "yarn build" 32 | }, 33 | "dependencies": { 34 | "@boringnode/bus": "^0.7.1", 35 | "@poppinss/utils": "^6.9.4", 36 | "emittery": "^1.1.0", 37 | "matchit": "^1.1.0" 38 | }, 39 | "devDependencies": { 40 | "@adonisjs/eslint-config": "^2.0.0", 41 | "@adonisjs/prettier-config": "^1.4.4", 42 | "@adonisjs/tsconfig": "^1.4.0", 43 | "@japa/assert": "^4.0.1", 44 | "@japa/runner": "^4.2.0", 45 | "@swc/core": "^1.11.29", 46 | "@types/node": "^20.16.10", 47 | "c8": "^10.1.3", 48 | "del-cli": "^6.0.0", 49 | "eslint": "^9.27.0", 50 | "prettier": "^3.5.3", 51 | "release-it": "^18.1.2", 52 | "ts-node-maintained": "^10.9.5", 53 | "tsup": "^8.5.0", 54 | "typescript": "^5.8.3" 55 | }, 56 | "author": "Romain Lanz ", 57 | "license": "MIT", 58 | "keywords": [ 59 | "sse", 60 | "server-sent-event", 61 | "realtime", 62 | "real-time" 63 | ], 64 | "prettier": "@adonisjs/prettier-config", 65 | "publishConfig": { 66 | "access": "public", 67 | "tag": "latest" 68 | }, 69 | "release-it": { 70 | "git": { 71 | "commitMessage": "chore(release): ${version}", 72 | "tagAnnotation": "v${version}", 73 | "tagName": "v${version}" 74 | }, 75 | "github": { 76 | "release": true, 77 | "releaseName": "v${version}", 78 | "web": true 79 | } 80 | }, 81 | "c8": { 82 | "reporter": [ 83 | "text", 84 | "html" 85 | ], 86 | "exclude": [ 87 | "tests/**" 88 | ] 89 | }, 90 | "volta": { 91 | "node": "20.11.1" 92 | }, 93 | "packageManager": "yarn@4.9.1" 94 | } 95 | -------------------------------------------------------------------------------- /src/storage.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @boringnode/transmit 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import matchit from 'matchit' 9 | import { Stream } from './stream.js' 10 | import type { Route } from 'matchit' 11 | 12 | export class Storage { 13 | /** 14 | * Channels subscribed to a given stream 15 | */ 16 | #subscriptions = new Map>() 17 | 18 | /** 19 | * Channels subscribed to a given Stream UID 20 | */ 21 | #channelsByUid = new Map>() 22 | 23 | /** 24 | * Secured channels definition 25 | */ 26 | #securedChannelsDefinition: Route[] = [] 27 | 28 | /** 29 | * Secure a channel 30 | */ 31 | secure(channel: string) { 32 | const encodedDefinition = matchit.parse(channel) 33 | 34 | this.#securedChannelsDefinition.push(encodedDefinition) 35 | } 36 | 37 | /** 38 | * Check if a channel is secured and return the matched channel 39 | */ 40 | getSecuredChannelDefinition(channel: string) { 41 | const matchedChannel = matchit.match(channel, this.#securedChannelsDefinition) 42 | 43 | if (matchedChannel.length > 0) { 44 | const params = matchit.exec(channel, matchedChannel) 45 | return { params, channel: matchedChannel[0].old } 46 | } 47 | } 48 | 49 | /** 50 | * Get the number of secured channels 51 | */ 52 | getSecuredChannelCount() { 53 | return this.#securedChannelsDefinition.length 54 | } 55 | 56 | /** 57 | * Get the number of streams 58 | */ 59 | getStreamCount() { 60 | return this.#subscriptions.size 61 | } 62 | 63 | /** 64 | * Add a stream to the storage 65 | */ 66 | add(stream: Stream) { 67 | const channels = new Set() 68 | 69 | this.#subscriptions.set(stream, channels) 70 | this.#channelsByUid.set(stream.getUid(), channels) 71 | } 72 | 73 | /** 74 | * Remove a stream from the storage 75 | */ 76 | remove(stream: Stream) { 77 | this.#subscriptions.delete(stream) 78 | this.#channelsByUid.delete(stream.getUid()) 79 | } 80 | 81 | /** 82 | * Add a channel to a stream 83 | */ 84 | subscribe(uid: string, channel: string) { 85 | const channels = this.#channelsByUid.get(uid) 86 | 87 | if (!channels) return false 88 | 89 | channels.add(channel) 90 | 91 | return true 92 | } 93 | 94 | /** 95 | * Remove a channel from a stream 96 | */ 97 | unsubscribe(uid: string, channel: string) { 98 | const channels = this.#channelsByUid.get(uid) 99 | 100 | if (!channels) return false 101 | 102 | channels.delete(channel) 103 | 104 | return true 105 | } 106 | 107 | /** 108 | * Find all subscribers to a channel 109 | */ 110 | findByChannel(channel: string) { 111 | const subscribers = new Set() 112 | 113 | for (const [stream, streamChannels] of this.#subscriptions) { 114 | if (streamChannels.has(channel)) { 115 | subscribers.add(stream) 116 | } 117 | } 118 | 119 | return subscribers 120 | } 121 | 122 | /** 123 | * Get channels for a given client 124 | */ 125 | getChannelByClient(uid: string) { 126 | return this.#channelsByUid.get(uid) 127 | } 128 | 129 | /** 130 | * Get all subscribers 131 | */ 132 | getAllSubscribers() { 133 | return this.#subscriptions 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/stream.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @boringnode/transmit 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import { Transform } from 'node:stream' 9 | import { dataToString } from './utils.js' 10 | import type { IncomingMessage, OutgoingHttpHeaders } from 'node:http' 11 | import type { Broadcastable } from './types/main.js' 12 | 13 | interface Message { 14 | data: Broadcastable 15 | } 16 | 17 | interface WriteHeaders { 18 | writeHead?(statusCode: number, headers?: OutgoingHttpHeaders): WriteHeaders 19 | flushHeaders?(): void 20 | } 21 | 22 | export type HeaderStream = NodeJS.WritableStream & WriteHeaders 23 | 24 | export class Stream extends Transform { 25 | readonly #uid: string 26 | 27 | constructor(uid: string, request?: IncomingMessage) { 28 | super({ objectMode: true }) 29 | 30 | this.#uid = uid 31 | 32 | if (request?.socket) { 33 | request.socket.setKeepAlive(true) 34 | request.socket.setNoDelay(true) 35 | request.socket.setTimeout(0) 36 | } 37 | } 38 | 39 | getUid() { 40 | return this.#uid 41 | } 42 | 43 | pipe( 44 | destination: T, 45 | options?: { end?: boolean }, 46 | injectResponseHeaders: Record = {} 47 | ): T { 48 | if (destination.writeHead) { 49 | // @see https://github.com/dunglas/mercure/blob/9e080c8dc9a141d4294412d14efdecfb15bf7f43/subscribe.go#L219 50 | destination.writeHead(200, { 51 | ...injectResponseHeaders, 52 | 'Cache-Control': 'private, no-cache, no-store, must-revalidate, max-age=0, no-transform', 53 | 'Connection': 'keep-alive', 54 | 'Content-Type': 'text/event-stream', 55 | 'Expire': '0', 56 | 'Pragma': 'no-cache', 57 | // @see https://www.nginx.com/resources/wiki/start/topics/examples/x-accel/#x-accel-buffering 58 | 'X-Accel-Buffering': 'no', 59 | }) 60 | 61 | destination.flushHeaders?.() 62 | } 63 | 64 | // Some clients (Safari) don't trigger onopen until the first frame is received. 65 | destination.write(':ok\n\n') 66 | return super.pipe(destination, options) 67 | } 68 | 69 | _transform( 70 | message: Message, 71 | _encoding: string, 72 | callback: (error?: Error | null, data?: any) => void 73 | ) { 74 | if (message.data) { 75 | this.push(dataToString(message.data)) 76 | } 77 | 78 | this.push('\n') 79 | 80 | callback() 81 | } 82 | 83 | writeMessage( 84 | message: Message, 85 | encoding?: BufferEncoding, 86 | cb?: (error: Error | null | undefined) => void 87 | ): boolean { 88 | return this.write(message, encoding, cb) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/stream_manager.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @boringnode/transmit 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import { Stream } from './stream.js' 9 | import { Storage } from './storage.js' 10 | import type { IncomingMessage, ServerResponse } from 'node:http' 11 | import type { AccessCallback } from './types/main.js' 12 | 13 | interface OnConnectParams { 14 | uid: string 15 | context?: Context 16 | } 17 | 18 | interface OnDisconnectParams { 19 | uid: string 20 | context?: Context 21 | } 22 | 23 | interface OnSubscribeParams { 24 | uid: string 25 | channel: string 26 | context: Context 27 | } 28 | 29 | interface OnUnsubscribeParams { 30 | uid: string 31 | channel: string 32 | context: Context 33 | } 34 | 35 | export interface CreateStreamParams { 36 | uid: string 37 | request: IncomingMessage 38 | response: ServerResponse 39 | context: Context 40 | injectResponseHeaders?: Record 41 | onConnect?: (params: OnConnectParams) => void 42 | onDisconnect?: (params: OnDisconnectParams) => void 43 | } 44 | 45 | export interface SubscribeParams { 46 | uid: string 47 | channel: string 48 | context?: Context 49 | skipAuthorization?: boolean 50 | onSubscribe?: (params: OnSubscribeParams) => void 51 | } 52 | 53 | export interface UnsubscribeParams { 54 | uid: string 55 | channel: string 56 | context?: Context 57 | onUnsubscribe?: (params: OnUnsubscribeParams) => void 58 | } 59 | 60 | export class StreamManager { 61 | #storage: Storage 62 | 63 | #securedChannels = new Map>() 64 | 65 | constructor() { 66 | this.#storage = new Storage() 67 | } 68 | 69 | createStream({ 70 | uid, 71 | context, 72 | request, 73 | response, 74 | injectResponseHeaders, 75 | onConnect, 76 | onDisconnect, 77 | }: CreateStreamParams) { 78 | const stream = new Stream(uid, request) 79 | stream.pipe(response, undefined, injectResponseHeaders) 80 | 81 | this.#storage.add(stream) 82 | 83 | onConnect?.({ uid, context }) 84 | 85 | response.on('close', () => { 86 | this.#storage.remove(stream) 87 | onDisconnect?.({ uid, context }) 88 | }) 89 | 90 | return stream 91 | } 92 | 93 | async subscribe({ 94 | uid, 95 | channel, 96 | context, 97 | skipAuthorization = false, 98 | onSubscribe, 99 | }: SubscribeParams) { 100 | if (!skipAuthorization) { 101 | const canAccessChannel = await this.verifyAccess(channel, context!) 102 | 103 | if (!canAccessChannel) { 104 | return false 105 | } 106 | } 107 | 108 | this.#storage.subscribe(uid, channel) 109 | onSubscribe?.({ uid, channel, context: context! }) 110 | 111 | return true 112 | } 113 | 114 | async unsubscribe({ uid, channel, context, onUnsubscribe }: UnsubscribeParams) { 115 | this.#storage.unsubscribe(uid, channel) 116 | onUnsubscribe?.({ uid, channel, context: context! }) 117 | 118 | return true 119 | } 120 | 121 | authorize>( 122 | channel: string, 123 | callback: AccessCallback 124 | ) { 125 | this.#storage.secure(channel) 126 | this.#securedChannels.set(channel, callback) 127 | } 128 | 129 | async verifyAccess(channel: string, context: Context) { 130 | const definitions = this.#storage.getSecuredChannelDefinition(channel) 131 | 132 | if (!definitions) { 133 | return true 134 | } 135 | 136 | const callback = this.#securedChannels.get(definitions.channel) 137 | 138 | try { 139 | return await callback!(context, definitions.params) 140 | } catch (e) { 141 | return false 142 | } 143 | } 144 | 145 | /** 146 | * Get all subscribers 147 | */ 148 | getAllSubscribers() { 149 | return this.#storage.getAllSubscribers() 150 | } 151 | 152 | /** 153 | * Find all subscribers to a channel 154 | */ 155 | findByChannel(channel: string) { 156 | return this.#storage.findByChannel(channel) 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/transmit.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @boringnode/transmit 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import { clearInterval } from 'node:timers' 9 | import Emittery from 'emittery' 10 | import { Bus } from '@boringnode/bus' 11 | import string from '@poppinss/utils/string' 12 | import { StreamManager } from './stream_manager.js' 13 | import { TransportMessageType } from './transport_message_type.js' 14 | import type { Transport } from '@boringnode/bus/types/main' 15 | import type { AccessCallback, Broadcastable, TransmitConfig } from './types/main.js' 16 | import type { CreateStreamParams, SubscribeParams, UnsubscribeParams } from './stream_manager.js' 17 | 18 | export interface TransmitLifecycleHooks { 19 | connect: { uid: string; context: Context } 20 | disconnect: { uid: string; context: Context } 21 | broadcast: { channel: string; payload: Broadcastable } 22 | subscribe: { uid: string; channel: string; context: Context } 23 | unsubscribe: { uid: string; channel: string; context: Context } 24 | } 25 | 26 | type TransmitMessage = 27 | | { 28 | type: typeof TransportMessageType.Broadcast 29 | channel: string 30 | payload: Broadcastable 31 | } 32 | | { 33 | type: typeof TransportMessageType.Subscribe 34 | channel: string 35 | payload: { uid: string } 36 | } 37 | | { 38 | type: typeof TransportMessageType.Unsubscribe 39 | channel: string 40 | payload: { uid: string } 41 | } 42 | 43 | export class Transmit { 44 | /** 45 | * The configuration for the transmit instance 46 | */ 47 | #config: TransmitConfig 48 | 49 | /** 50 | * The stream manager instance 51 | */ 52 | readonly #manager: StreamManager 53 | 54 | /** 55 | * The transport channel to synchronize messages and subscriptions 56 | * across multiple instance. 57 | */ 58 | readonly #transportChannel: string 59 | 60 | /** 61 | * The transport provider to synchronize messages and subscriptions 62 | * across multiple instance. 63 | */ 64 | readonly #bus: Bus | null 65 | 66 | /** 67 | * The emittery instance to emit events. 68 | */ 69 | #emittery: Emittery> 70 | 71 | /** 72 | * The interval to send ping messages to all the subscribers. 73 | */ 74 | readonly #interval: NodeJS.Timeout | undefined 75 | 76 | constructor(config: TransmitConfig, transport?: Transport | null) { 77 | this.#config = config 78 | this.#manager = new StreamManager() 79 | this.#emittery = new Emittery() 80 | this.#bus = transport ? new Bus(transport, { retryQueue: { enabled: true } }) : null 81 | this.#transportChannel = this.#config.transport?.channel ?? 'transmit::broadcast' 82 | 83 | // Subscribe to the transport channel and handle incoming messages 84 | void this.#bus?.subscribe(this.#transportChannel, (message) => { 85 | const { type, channel, payload } = message 86 | 87 | if (type === TransportMessageType.Broadcast) { 88 | void this.#broadcastLocally(channel, payload) 89 | } else if (type === TransportMessageType.Subscribe) { 90 | void this.#subscribeLocally({ uid: payload.uid, channel }) 91 | } else if (type === TransportMessageType.Unsubscribe) { 92 | void this.#unsubscribeLocally({ uid: payload.uid, channel }) 93 | } 94 | }) 95 | 96 | // Start the ping interval if configured 97 | if (this.#config.pingInterval) { 98 | const intervalValue = 99 | typeof this.#config.pingInterval === 'number' 100 | ? this.#config.pingInterval 101 | : string.milliseconds.parse(this.#config.pingInterval) 102 | 103 | this.#interval = setInterval(() => this.#ping(), intervalValue) 104 | } 105 | } 106 | 107 | getManager() { 108 | return this.#manager 109 | } 110 | 111 | createStream(params: Omit, 'onConnect' | 'onDisconnect'>) { 112 | return this.#manager.createStream({ 113 | ...params, 114 | onConnect: () => { 115 | void this.#emittery.emit('connect', { 116 | uid: params.uid, 117 | context: params.context, 118 | }) 119 | }, 120 | onDisconnect: () => { 121 | void this.#emittery.emit('disconnect', { 122 | uid: params.uid, 123 | context: params.context, 124 | }) 125 | }, 126 | }) 127 | } 128 | 129 | authorize>( 130 | channel: string, 131 | callback: AccessCallback 132 | ) { 133 | this.#manager.authorize(channel, callback) 134 | } 135 | 136 | #subscribeLocally(params: Omit, 'onSubscribe'>) { 137 | return this.#manager.subscribe({ 138 | ...params, 139 | skipAuthorization: true, 140 | }) 141 | } 142 | 143 | subscribe(params: Omit, 'onSubscribe'>) { 144 | return this.#manager.subscribe({ 145 | ...params, 146 | onSubscribe: ({ uid, channel, context }) => { 147 | void this.#emittery.emit('subscribe', { 148 | uid, 149 | channel, 150 | context, 151 | }) 152 | 153 | void this.#bus?.publish(this.#transportChannel, { 154 | type: TransportMessageType.Subscribe, 155 | channel, 156 | payload: { uid }, 157 | }) 158 | }, 159 | }) 160 | } 161 | 162 | #unsubscribeLocally(params: Omit, 'onUnsubscribe'>) { 163 | return this.#manager.unsubscribe({ 164 | ...params, 165 | }) 166 | } 167 | 168 | unsubscribe(params: Omit, 'onUnsubscribe'>) { 169 | return this.#manager.unsubscribe({ 170 | ...params, 171 | onUnsubscribe: ({ uid, channel, context }) => { 172 | void this.#emittery.emit('unsubscribe', { 173 | uid, 174 | channel, 175 | context, 176 | }) 177 | 178 | void this.#bus?.publish(this.#transportChannel, { 179 | type: TransportMessageType.Unsubscribe, 180 | channel, 181 | payload: { uid }, 182 | }) 183 | }, 184 | }) 185 | } 186 | 187 | #broadcastLocally(channel: string, payload: Broadcastable, senderUid?: string | string[]) { 188 | const subscribers = this.#manager.findByChannel(channel) 189 | 190 | for (const subscriber of subscribers) { 191 | if ( 192 | Array.isArray(senderUid) 193 | ? senderUid.includes(subscriber.getUid()) 194 | : senderUid === subscriber.getUid() 195 | ) { 196 | continue 197 | } 198 | 199 | subscriber.writeMessage({ data: { channel, payload } }) 200 | } 201 | } 202 | 203 | broadcastExcept(channel: string, payload: Broadcastable, senderUid: string | string[]) { 204 | return this.#broadcastLocally(channel, payload, senderUid) 205 | } 206 | 207 | broadcast(channel: string, payload?: Broadcastable) { 208 | if (!payload) { 209 | payload = {} 210 | } 211 | 212 | void this.#bus?.publish(this.#transportChannel, { 213 | type: TransportMessageType.Broadcast, 214 | channel, 215 | payload, 216 | }) 217 | 218 | this.#broadcastLocally(channel, payload) 219 | 220 | void this.#emittery.emit('broadcast', { channel, payload }) 221 | } 222 | 223 | on>( 224 | event: T, 225 | callback: (payload: TransmitLifecycleHooks[T]) => void 226 | ) { 227 | return this.#emittery.on(event, callback) 228 | } 229 | 230 | async shutdown() { 231 | if (this.#interval) { 232 | clearInterval(this.#interval) 233 | } 234 | 235 | await this.#bus?.disconnect() 236 | } 237 | 238 | #ping() { 239 | for (const [stream] of this.#manager.getAllSubscribers()) { 240 | stream.writeMessage({ data: { channel: '$$transmit/ping', payload: {} } }) 241 | } 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /src/transport_message_type.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @boringnode/transmit 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | export const TransportMessageType = { 9 | Broadcast: 1, 10 | Subscribe: 2, 11 | Unsubscribe: 3, 12 | } as const 13 | -------------------------------------------------------------------------------- /src/types/main.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @boringnode/transmit 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import type { TransportFactory } from '@boringnode/bus/types/main' 9 | 10 | /** 11 | * A Duration can be a number in milliseconds or a string formatted as a duration 12 | * 13 | * Formats accepted are : 14 | * - Simple number in milliseconds 15 | * - String formatted as a duration. Uses https://github.com/lukeed/ms under the hood 16 | */ 17 | export type Duration = number | string 18 | 19 | /** 20 | * A Broadcastable is a value that can be broadcasted to other clients 21 | */ 22 | export type Broadcastable = 23 | | { [key: string]: Broadcastable } 24 | | string 25 | | number 26 | | boolean 27 | | null 28 | | Broadcastable[] 29 | 30 | export interface TransmitConfig { 31 | /** 32 | * The interval in milliseconds to send ping messages to the client 33 | */ 34 | pingInterval?: Duration | false 35 | 36 | /** 37 | * The transport driver to use for transmitting messages 38 | */ 39 | transport: null | { 40 | driver: TransportFactory 41 | channel?: string 42 | } 43 | } 44 | 45 | export type AccessCallback> = ( 46 | context: Context, 47 | params: Params 48 | ) => Promise | boolean 49 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @boringnode/transmit 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import { Broadcastable } from './types/main.js' 9 | 10 | export function dataToString(data: Broadcastable): string { 11 | if (typeof data === 'object') { 12 | return dataToString(JSON.stringify(data)) 13 | } 14 | 15 | if (typeof data === 'number' || typeof data === 'boolean') { 16 | return `data: ${data}\n` 17 | } 18 | 19 | return data 20 | .split(/\r\n|\r|\n/) 21 | .map((line) => `data: ${line}\n`) 22 | .join('') 23 | } 24 | -------------------------------------------------------------------------------- /tests/fixtures/stream.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @boringnode/transmit 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import { randomUUID } from 'node:crypto' 9 | import { IncomingMessage, ServerResponse } from 'node:http' 10 | import { Socket } from '../mocks/socket.js' 11 | import type { Transmit } from '../../src/transmit.js' 12 | 13 | export function makeStream(transmit: Transmit, uid = randomUUID()) { 14 | const socket = new Socket() 15 | const request = new IncomingMessage(socket) 16 | const response = new ServerResponse(request) 17 | 18 | return transmit.createStream({ 19 | uid, 20 | request, 21 | response, 22 | context: {}, 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /tests/fixtures/transmit.ts: -------------------------------------------------------------------------------- 1 | import { memory } from '@boringnode/bus/transports/memory' 2 | import { Transmit } from '../../src/transmit.js' 3 | 4 | export function makeTransport() { 5 | const transport = memory()() 6 | 7 | return { 8 | transport, 9 | driver: memory(), 10 | } 11 | } 12 | 13 | export function makeTransmitWithTransport(params: ReturnType) { 14 | return new Transmit( 15 | { 16 | transport: { 17 | driver: params.driver, 18 | }, 19 | }, 20 | params.transport 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /tests/mocks/sink.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @boringnode/transmit 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import { Writable } from 'node:stream' 9 | import type { HeaderStream } from '../../src/stream.js' 10 | 11 | export class Sink extends Writable implements HeaderStream { 12 | #chunks: Buffer[] = [] 13 | 14 | constructor() { 15 | super({ objectMode: true }) 16 | } 17 | 18 | assertWriteHead(assertion: (statusCode: number, headers: any) => void) { 19 | // @ts-expect-error - Mocking the writeHead method 20 | this.writeHead = (statusCode, headers) => { 21 | assertion(statusCode, headers) 22 | } 23 | } 24 | 25 | get content() { 26 | return this.#chunks.join('') 27 | } 28 | 29 | _write(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void { 30 | this.#chunks.push(chunk) 31 | callback() 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/mocks/socket.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @boringnode/transmit 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import { Socket as NodeSocket } from 'node:net' 9 | 10 | export class Socket extends NodeSocket { 11 | #keepAlive = false 12 | #noDelay = false 13 | #timeout = 0 14 | 15 | getKeepAlive() { 16 | return this.#keepAlive 17 | } 18 | 19 | getNoDelay() { 20 | return this.#noDelay 21 | } 22 | 23 | getTimeout() { 24 | return this.#timeout 25 | } 26 | 27 | setKeepAlive(enable?: boolean, initialDelay?: number): this { 28 | this.#keepAlive = enable === true 29 | return super.setKeepAlive(enable, initialDelay) 30 | } 31 | 32 | setNoDelay(noDelay?: boolean): this { 33 | this.#noDelay = noDelay === true 34 | return super.setNoDelay(noDelay) 35 | } 36 | 37 | setTimeout(timeout: number, callback?: () => void): this { 38 | this.#timeout = timeout 39 | return super.setTimeout(timeout, callback) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/storage.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @boringnode/transmit 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import { randomUUID } from 'node:crypto' 9 | import { test } from '@japa/runner' 10 | import { Stream } from '../src/stream.js' 11 | import { Storage } from '../src/storage.js' 12 | 13 | test.group('Storage', () => { 14 | test('should secure a channel', async ({ assert }) => { 15 | const storage = new Storage() 16 | 17 | storage.secure('foo') 18 | assert.equal(storage.getSecuredChannelCount(), 1) 19 | }) 20 | 21 | test('should get the secured channel definition', async ({ assert }) => { 22 | const storage = new Storage() 23 | 24 | storage.secure('foo') 25 | const definition = storage.getSecuredChannelDefinition('foo') 26 | 27 | assert.exists(definition) 28 | assert.equal(definition!.channel, 'foo') 29 | }) 30 | 31 | test('should not get the secured channel definition using params', async ({ assert }) => { 32 | const storage = new Storage() 33 | 34 | storage.secure('foo/:id') 35 | const definition = storage.getSecuredChannelDefinition('foo/1') 36 | 37 | assert.exists(definition) 38 | assert.equal(definition!.channel, 'foo/:id') 39 | assert.equal(definition!.params.id, '1') 40 | }) 41 | 42 | test('should add a stream to the storage', async ({ assert }) => { 43 | const stream1 = new Stream(randomUUID()) 44 | const storage = new Storage() 45 | 46 | storage.add(stream1) 47 | assert.equal(storage.getStreamCount(), 1) 48 | 49 | const stream2 = new Stream(randomUUID()) 50 | storage.add(stream2) 51 | assert.equal(storage.getStreamCount(), 2) 52 | }) 53 | 54 | test('should remove a stream from the storage', async ({ assert }) => { 55 | const stream = new Stream(randomUUID()) 56 | const storage = new Storage() 57 | 58 | storage.add(stream) 59 | assert.equal(storage.getStreamCount(), 1) 60 | 61 | storage.remove(stream) 62 | assert.equal(storage.getStreamCount(), 0) 63 | }) 64 | 65 | test('should subscribe a channel to a stream', async ({ assert }) => { 66 | const stream = new Stream(randomUUID()) 67 | const storage = new Storage() 68 | 69 | storage.add(stream) 70 | 71 | assert.isTrue(storage.subscribe(stream.getUid(), 'foo')) 72 | assert.isTrue(storage.subscribe(stream.getUid(), 'bar')) 73 | }) 74 | 75 | test('should not subscribe a channel to a stream that does not exist', async ({ assert }) => { 76 | const stream = new Stream(randomUUID()) 77 | const storage = new Storage() 78 | 79 | assert.isFalse(storage.subscribe(stream.getUid(), 'foo')) 80 | }) 81 | 82 | test('should unsubscribe a channel from a stream', async ({ assert }) => { 83 | const stream = new Stream(randomUUID()) 84 | const storage = new Storage() 85 | 86 | storage.add(stream) 87 | storage.subscribe(stream.getUid(), 'foo') 88 | 89 | assert.isTrue(storage.unsubscribe(stream.getUid(), 'foo')) 90 | }) 91 | 92 | test('should not unsubscribe a channel from a stream that does not exist', async ({ assert }) => { 93 | const stream = new Stream(randomUUID()) 94 | const storage = new Storage() 95 | 96 | assert.isFalse(storage.unsubscribe(stream.getUid(), 'foo')) 97 | }) 98 | 99 | test('should find all subscribers to a channel', async ({ assert }) => { 100 | const stream1 = new Stream(randomUUID()) 101 | const stream2 = new Stream(randomUUID()) 102 | const storage = new Storage() 103 | 104 | storage.add(stream1) 105 | storage.add(stream2) 106 | 107 | storage.subscribe(stream1.getUid(), 'foo') 108 | storage.subscribe(stream2.getUid(), 'foo') 109 | 110 | const subscribers = storage.findByChannel('foo') 111 | assert.equal(subscribers.size, 2) 112 | }) 113 | 114 | test('should return the channel of a client', async ({ assert }) => { 115 | const stream = new Stream(randomUUID()) 116 | const storage = new Storage() 117 | 118 | storage.add(stream) 119 | storage.subscribe(stream.getUid(), 'foo') 120 | 121 | const channels = storage.getChannelByClient(stream.getUid()) 122 | 123 | assert.exists(channels) 124 | assert.isTrue(channels!.has('foo')) 125 | }) 126 | 127 | test('should return all subscribers', async ({ assert }) => { 128 | const stream1 = new Stream(randomUUID()) 129 | const stream2 = new Stream(randomUUID()) 130 | const storage = new Storage() 131 | 132 | storage.add(stream1) 133 | storage.add(stream2) 134 | 135 | storage.subscribe(stream1.getUid(), 'foo') 136 | storage.subscribe(stream2.getUid(), 'foo') 137 | 138 | const subscribers = storage.getAllSubscribers() 139 | assert.equal(subscribers.size, 2) 140 | }) 141 | }) 142 | -------------------------------------------------------------------------------- /tests/stream.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @boringnode/transmit 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import { randomUUID } from 'node:crypto' 9 | import { IncomingMessage } from 'node:http' 10 | import { test } from '@japa/runner' 11 | import { Stream } from '../src/stream.js' 12 | import { Sink } from './mocks/sink.js' 13 | import { Socket } from './mocks/socket.js' 14 | 15 | test.group('Stream', () => { 16 | test('should get back the uid', async ({ assert }) => { 17 | const uid = randomUUID() 18 | const stream = new Stream(uid) 19 | 20 | assert.equal(stream.getUid(), uid) 21 | }) 22 | 23 | test('should write multiple chunks to the stream', async ({ assert }) => { 24 | const stream = new Stream(randomUUID()) 25 | const sink = new Sink() 26 | stream.pipe(sink) 27 | 28 | stream.writeMessage({ data: { channel: 'foo', payload: 'bar' } }) 29 | stream.writeMessage({ data: { channel: 'baz', payload: 'qux' } }) 30 | 31 | assert.equal( 32 | sink.content, 33 | [ 34 | `:ok\n\n`, 35 | `data: {"channel":"foo","payload":"bar"}\n\n`, 36 | `data: {"channel":"baz","payload":"qux"}\n\n`, 37 | ].join('') 38 | ) 39 | }) 40 | 41 | test('should sets headers on the response', async ({ assert }) => { 42 | assert.plan(2) 43 | 44 | const stream = new Stream(randomUUID()) 45 | const sink = new Sink() 46 | 47 | sink.assertWriteHead((statusCode, headers) => { 48 | assert.equal(statusCode, 200) 49 | assert.deepEqual(headers, { 50 | 'Cache-Control': 'private, no-cache, no-store, must-revalidate, max-age=0, no-transform', 51 | 'Connection': 'keep-alive', 52 | 'Content-Type': 'text/event-stream', 53 | 'Expire': '0', 54 | 'Pragma': 'no-cache', 55 | 'X-Accel-Buffering': 'no', 56 | }) 57 | }) 58 | 59 | stream.pipe(sink) 60 | }) 61 | 62 | test('should forward headers to the response', async ({ assert }) => { 63 | assert.plan(2) 64 | 65 | const stream = new Stream(randomUUID()) 66 | const sink = new Sink() 67 | 68 | sink.assertWriteHead((statusCode, headers) => { 69 | assert.equal(statusCode, 200) 70 | assert.deepEqual(headers, { 71 | 'Cache-Control': 'private, no-cache, no-store, must-revalidate, max-age=0, no-transform', 72 | 'Connection': 'keep-alive', 73 | 'Content-Type': 'text/event-stream', 74 | 'Expire': '0', 75 | 'Pragma': 'no-cache', 76 | 'X-Accel-Buffering': 'no', 77 | 'X-Foo': 'bar', 78 | }) 79 | }) 80 | 81 | stream.pipe(sink, undefined, { 'X-Foo': 'bar' }) 82 | }) 83 | 84 | test('should set the keep alive, no delay and timeout on the socket', async ({ assert }) => { 85 | const socket = new Socket() 86 | const incomingMessage = new IncomingMessage(socket) 87 | new Stream(randomUUID(), incomingMessage) 88 | 89 | assert.isTrue(socket.getKeepAlive()) 90 | assert.isTrue(socket.getNoDelay()) 91 | assert.equal(socket.getTimeout(), 0) 92 | }) 93 | }) 94 | -------------------------------------------------------------------------------- /tests/stream_manager.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @boringnode/transmit 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import { randomUUID } from 'node:crypto' 9 | import { test } from '@japa/runner' 10 | import { Stream } from '../src/stream.js' 11 | import { StreamManager } from '../src/stream_manager.js' 12 | import { Socket } from './mocks/socket.js' 13 | import { IncomingMessage, ServerResponse } from 'node:http' 14 | 15 | test.group('StreamManager', () => { 16 | test('should create stream', async ({ assert }) => { 17 | const socket = new Socket() 18 | const manager = new StreamManager() 19 | const request = new IncomingMessage(socket) 20 | const response = new ServerResponse(request) 21 | 22 | let channelConnected = false 23 | 24 | const stream = manager.createStream({ 25 | uid: randomUUID(), 26 | request, 27 | response, 28 | context: {}, 29 | onConnect() { 30 | channelConnected = true 31 | }, 32 | }) 33 | 34 | assert.instanceOf(stream, Stream) 35 | assert.isTrue(channelConnected) 36 | }) 37 | 38 | test('should remove stream if the response end', async ({ assert }) => { 39 | const socket = new Socket() 40 | const manager = new StreamManager() 41 | const request = new IncomingMessage(socket) 42 | const response = new ServerResponse(request) 43 | 44 | let channelDisconnected = false 45 | 46 | manager.createStream({ 47 | uid: randomUUID(), 48 | request, 49 | response, 50 | context: {}, 51 | onDisconnect() { 52 | channelDisconnected = true 53 | }, 54 | }) 55 | 56 | response.emit('close') 57 | 58 | assert.isTrue(channelDisconnected) 59 | }) 60 | 61 | test('should authorize channel', async ({ assert }) => { 62 | const manager = new StreamManager() 63 | 64 | manager.authorize('foo', () => true) 65 | manager.authorize('bar', () => false) 66 | 67 | assert.isTrue(await manager.verifyAccess('foo', {})) 68 | assert.isFalse(await manager.verifyAccess('bar', {})) 69 | }) 70 | 71 | test('should return true if channel is not secured', async ({ assert }) => { 72 | const manager = new StreamManager() 73 | 74 | assert.isTrue(await manager.verifyAccess('foo', {})) 75 | }) 76 | 77 | test('should return false if callback throws an error', async ({ assert }) => { 78 | const manager = new StreamManager() 79 | 80 | manager.authorize('foo', () => { 81 | throw new Error('Error') 82 | }) 83 | 84 | assert.isFalse(await manager.verifyAccess('foo', {})) 85 | }) 86 | 87 | test('should send context to the callback', async ({ assert }) => { 88 | assert.plan(2) 89 | 90 | const manager = new StreamManager() 91 | const context = { foo: 'bar' } 92 | 93 | manager.authorize('foo', (ctx) => { 94 | assert.deepEqual(ctx, context) 95 | return true 96 | }) 97 | 98 | assert.isTrue(await manager.verifyAccess('foo', context)) 99 | }) 100 | 101 | test('should retrieve params from the channel', async ({ assert }) => { 102 | const manager = new StreamManager() 103 | 104 | manager.authorize('users/:id', (_ctx, params) => { 105 | assert.deepEqual(params, { id: '1' }) 106 | return true 107 | }) 108 | 109 | assert.isTrue(await manager.verifyAccess('users/1', {})) 110 | }) 111 | 112 | test('should subscribe to a channel', async ({ assert }) => { 113 | const manager = new StreamManager() 114 | 115 | assert.isTrue(await manager.subscribe({ uid: randomUUID(), channel: 'foo', context: {} })) 116 | }) 117 | 118 | test('should not subscribe to a channel if not authorized', async ({ assert }) => { 119 | const manager = new StreamManager() 120 | 121 | manager.authorize('foo', () => false) 122 | 123 | assert.isFalse(await manager.subscribe({ uid: randomUUID(), channel: 'foo', context: {} })) 124 | }) 125 | 126 | test('should call onSubscribe callback', async ({ assert }) => { 127 | const manager = new StreamManager() 128 | let subscribed = false 129 | 130 | await manager.subscribe({ 131 | uid: randomUUID(), 132 | channel: 'foo', 133 | context: {}, 134 | onSubscribe() { 135 | subscribed = true 136 | }, 137 | }) 138 | 139 | assert.isTrue(subscribed) 140 | }) 141 | 142 | test('should unsubscribe from a channel', async ({ assert }) => { 143 | const manager = new StreamManager() 144 | 145 | await manager.subscribe({ uid: randomUUID(), channel: 'foo', context: {} }) 146 | assert.isTrue(await manager.unsubscribe({ uid: randomUUID(), channel: 'foo', context: {} })) 147 | }) 148 | 149 | test('should call onUnsubscribe callback', async ({ assert }) => { 150 | const manager = new StreamManager() 151 | let unsubscribed = false 152 | 153 | await manager.subscribe({ uid: randomUUID(), channel: 'foo', context: {} }) 154 | 155 | await manager.unsubscribe({ 156 | uid: randomUUID(), 157 | channel: 'foo', 158 | context: {}, 159 | onUnsubscribe() { 160 | unsubscribed = true 161 | }, 162 | }) 163 | 164 | assert.isTrue(unsubscribed) 165 | }) 166 | 167 | test('should get all subscribers', async ({ assert }) => { 168 | const manager = new StreamManager() 169 | 170 | const socket1 = new Socket() 171 | const request1 = new IncomingMessage(socket1) 172 | const response1 = new ServerResponse(request1) 173 | const stream1 = manager.createStream({ 174 | uid: randomUUID(), 175 | request: request1, 176 | response: response1, 177 | context: {}, 178 | }) 179 | 180 | const socket2 = new Socket() 181 | const request2 = new IncomingMessage(socket2) 182 | const response2 = new ServerResponse(request2) 183 | const stream2 = manager.createStream({ 184 | uid: randomUUID(), 185 | request: request2, 186 | response: response2, 187 | context: {}, 188 | }) 189 | 190 | await manager.subscribe({ uid: stream1.getUid(), channel: 'foo' }) 191 | await manager.subscribe({ uid: stream2.getUid(), channel: 'bar' }) 192 | 193 | const subscribers = manager.getAllSubscribers() 194 | 195 | assert.equal(subscribers.size, 2) 196 | }) 197 | 198 | test('should find subscribers for a given channel', async ({ assert }) => { 199 | const manager = new StreamManager() 200 | 201 | const socket1 = new Socket() 202 | const request1 = new IncomingMessage(socket1) 203 | const response1 = new ServerResponse(request1) 204 | const stream1 = manager.createStream({ 205 | uid: randomUUID(), 206 | request: request1, 207 | response: response1, 208 | context: {}, 209 | }) 210 | 211 | const socket2 = new Socket() 212 | const request2 = new IncomingMessage(socket2) 213 | const response2 = new ServerResponse(request2) 214 | const stream2 = manager.createStream({ 215 | uid: randomUUID(), 216 | request: request2, 217 | response: response2, 218 | context: {}, 219 | }) 220 | 221 | await manager.subscribe({ uid: stream1.getUid(), channel: 'foo' }) 222 | await manager.subscribe({ uid: stream2.getUid(), channel: 'foo' }) 223 | 224 | const subscribers = manager.findByChannel('foo') 225 | 226 | assert.equal(subscribers.size, 2) 227 | }) 228 | }) 229 | -------------------------------------------------------------------------------- /tests/transmit.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @boringnode/transmit 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import { randomUUID } from 'node:crypto' 9 | import { test } from '@japa/runner' 10 | import { Transmit } from '../src/transmit.js' 11 | import { StreamManager } from '../src/stream_manager.js' 12 | import { makeStream } from './fixtures/stream.js' 13 | import { TransportMessageType } from '../src/transport_message_type.js' 14 | import { makeTransmitWithTransport, makeTransport } from './fixtures/transmit.js' 15 | 16 | test.group('Transmit', () => { 17 | test('should return the manager instance', async ({ assert }) => { 18 | const transmit = new Transmit({ 19 | transport: null, 20 | }) 21 | 22 | assert.instanceOf(transmit.getManager(), StreamManager) 23 | }) 24 | 25 | test('should call the authorization callback', async ({ assert }) => { 26 | const transmit = new Transmit({ 27 | transport: null, 28 | }) 29 | 30 | const uid = randomUUID() 31 | let authorized = false 32 | 33 | transmit.authorize('channel1', () => { 34 | authorized = true 35 | return true 36 | }) 37 | 38 | await transmit.subscribe({ channel: 'channel1', uid }) 39 | 40 | assert.isTrue(authorized) 41 | }) 42 | 43 | test('should call the authorization callback with the context', async ({ assert }) => { 44 | assert.plan(1) 45 | 46 | const transmit = new Transmit({ 47 | transport: null, 48 | }) 49 | 50 | const uid = randomUUID() 51 | 52 | transmit.authorize('channel1', (context) => { 53 | assert.equal(context, 'foo') 54 | return true 55 | }) 56 | 57 | await transmit.subscribe({ channel: 'channel1', uid, context: 'foo' }) 58 | }) 59 | 60 | test('should authorize the subscription', async ({ assert }) => { 61 | const transmit = new Transmit({ 62 | transport: null, 63 | }) 64 | 65 | const uid = randomUUID() 66 | 67 | transmit.authorize('channel1', () => { 68 | return true 69 | }) 70 | 71 | const authorized = await transmit.subscribe({ channel: 'channel1', uid }) 72 | 73 | assert.isTrue(authorized) 74 | }) 75 | 76 | test('should not authorize the subscription', async ({ assert }) => { 77 | const transmit = new Transmit({ 78 | transport: null, 79 | }) 80 | 81 | const uid = randomUUID() 82 | 83 | transmit.authorize('channel1', () => { 84 | return false 85 | }) 86 | 87 | const authorized = await transmit.subscribe({ channel: 'channel1', uid }) 88 | 89 | assert.isFalse(authorized) 90 | }) 91 | 92 | test('should authorize with channel params', async ({ assert }) => { 93 | const transmit = new Transmit({ 94 | transport: null, 95 | }) 96 | 97 | const uid = randomUUID() 98 | 99 | transmit.authorize<{ id: string }>('channel/:id', (_context, params) => { 100 | return params.id === '1' 101 | }) 102 | 103 | const authorized = await transmit.subscribe({ channel: 'channel/1', uid }) 104 | const refused = await transmit.subscribe({ channel: 'channel/2', uid }) 105 | 106 | assert.isTrue(authorized) 107 | assert.isFalse(refused) 108 | }) 109 | 110 | test('should emit an connect event', async ({ assert }, done) => { 111 | assert.plan(2) 112 | 113 | const transmit = new Transmit({ 114 | transport: null, 115 | }) 116 | 117 | const uid = randomUUID() 118 | let connected = false 119 | 120 | transmit.on('connect', (params) => { 121 | connected = true 122 | 123 | assert.equal(params.uid, uid) 124 | }) 125 | 126 | makeStream(transmit, uid) 127 | 128 | setTimeout(() => { 129 | assert.isTrue(connected) 130 | done() 131 | }, 0) 132 | }).waitForDone() 133 | 134 | test('should emit an subscribe event', async ({ assert }) => { 135 | assert.plan(3) 136 | 137 | const transmit = new Transmit({ 138 | transport: null, 139 | }) 140 | 141 | const uid = randomUUID() 142 | let subscribed = false 143 | 144 | transmit.on('subscribe', (params) => { 145 | subscribed = true 146 | 147 | assert.equal(params.uid, uid) 148 | assert.equal(params.channel, 'users/1') 149 | }) 150 | 151 | await transmit.subscribe({ 152 | uid, 153 | channel: 'users/1', 154 | }) 155 | 156 | assert.isTrue(subscribed) 157 | }) 158 | 159 | test('should emit an unsubscribe event', async ({ assert }) => { 160 | assert.plan(3) 161 | 162 | const transmit = new Transmit({ 163 | transport: null, 164 | }) 165 | 166 | const uid = randomUUID() 167 | let unsubscribed = false 168 | 169 | transmit.on('unsubscribe', (params) => { 170 | unsubscribed = true 171 | 172 | assert.equal(params.uid, uid) 173 | assert.equal(params.channel, 'users/1') 174 | }) 175 | 176 | await transmit.unsubscribe({ 177 | uid, 178 | channel: 'users/1', 179 | }) 180 | 181 | assert.isTrue(unsubscribed) 182 | }) 183 | 184 | test('should emit a broadcast event when a message is broadcasted', async ({ assert }, done) => { 185 | assert.plan(3) 186 | 187 | const transmit = new Transmit({ 188 | transport: null, 189 | }) 190 | 191 | const payload = { foo: 'bar' } 192 | 193 | let broadcasted = false 194 | 195 | transmit.on('broadcast', (params) => { 196 | broadcasted = true 197 | 198 | assert.equal(params.channel, 'users/1') 199 | assert.equal(params.payload, payload) 200 | }) 201 | 202 | transmit.broadcast('users/1', payload) 203 | 204 | setTimeout(() => { 205 | assert.isTrue(broadcasted) 206 | done() 207 | }, 0) 208 | }).waitForDone() 209 | 210 | test('should ping all subscribers', async ({ assert, cleanup }, done) => { 211 | assert.plan(1) 212 | 213 | const transmit = new Transmit({ 214 | transport: null, 215 | pingInterval: 100, 216 | }) 217 | 218 | cleanup(() => transmit.shutdown()) 219 | 220 | const stream = makeStream(transmit, randomUUID()) 221 | 222 | stream.on('data', (message: any) => { 223 | //? Ignore the first message 224 | if (message === '\n') return 225 | 226 | assert.include(message, '$$transmit/ping') 227 | done() 228 | }) 229 | }).waitForDone() 230 | 231 | test('should broadcast a message to all listening clients', async ({ assert }) => { 232 | assert.plan(1) 233 | 234 | const transmit = new Transmit({ 235 | transport: null, 236 | }) 237 | 238 | const stream = makeStream(transmit) 239 | const stream2 = makeStream(transmit) 240 | 241 | await transmit.subscribe({ 242 | uid: stream.getUid(), 243 | channel: 'channel1', 244 | }) 245 | 246 | let dataReceived = false 247 | stream.on('data', (message: any) => { 248 | //? Ignore the first message 249 | if (message === '\n') return 250 | 251 | dataReceived = true 252 | }) 253 | 254 | stream2.on('data', () => { 255 | assert.fail('Should not receive the broadcasted message') 256 | }) 257 | 258 | transmit.broadcast('channel1', { message: 'hello' }) 259 | 260 | assert.isTrue(dataReceived) 261 | }) 262 | 263 | test('should broadcast a message to all listening clients except the sender', async ({ 264 | assert, 265 | }) => { 266 | assert.plan(1) 267 | 268 | const transmit = new Transmit({ 269 | transport: null, 270 | }) 271 | 272 | const stream = makeStream(transmit) 273 | const stream2 = makeStream(transmit) 274 | 275 | await transmit.subscribe({ 276 | uid: stream.getUid(), 277 | channel: 'channel1', 278 | }) 279 | 280 | await transmit.subscribe({ 281 | uid: stream2.getUid(), 282 | channel: 'channel1', 283 | }) 284 | 285 | let dataReceived = false 286 | stream.on('data', (message: any) => { 287 | //? Ignore the first message 288 | if (message === '\n') return 289 | 290 | dataReceived = true 291 | }) 292 | 293 | stream2.on('data', () => { 294 | assert.fail('Should not receive the broadcasted message') 295 | }) 296 | 297 | transmit.broadcastExcept('channel1', { message: 'hello' }, stream2.getUid()) 298 | 299 | assert.isTrue(dataReceived) 300 | }) 301 | 302 | test('should not broadcast to ourself when sending to the bus', async ({ assert }) => { 303 | const transport = makeTransport() 304 | const transmit = makeTransmitWithTransport(transport) 305 | 306 | const stream = makeStream(transmit) 307 | 308 | await transmit.subscribe({ 309 | uid: stream.getUid(), 310 | channel: 'channel1', 311 | }) 312 | 313 | transmit.broadcast('channel1', { message: 'hello' }) 314 | 315 | assert.lengthOf(transport.transport.receivedMessages, 0) 316 | }) 317 | 318 | test('should broadcast to the bus when a client subscribe to a channel', async ({ assert }) => { 319 | const transport = makeTransport() 320 | const transmit = makeTransmitWithTransport(transport) 321 | makeTransmitWithTransport(transport) 322 | 323 | const stream = makeStream(transmit) 324 | 325 | await transmit.subscribe({ 326 | uid: stream.getUid(), 327 | channel: 'channel1', 328 | }) 329 | 330 | assert.lengthOf(transport.transport.receivedMessages, 1) 331 | assert.equal(transport.transport.receivedMessages[0].type, TransportMessageType.Subscribe) 332 | }) 333 | 334 | test('should broadcast to the bus when a client unsubscribe a channel', async ({ assert }) => { 335 | const transport = makeTransport() 336 | const transmit = makeTransmitWithTransport(transport) 337 | 338 | makeTransmitWithTransport(transport) 339 | 340 | const stream = makeStream(transmit) 341 | 342 | await transmit.subscribe({ 343 | uid: stream.getUid(), 344 | channel: 'channel1', 345 | }) 346 | 347 | await transmit.unsubscribe({ 348 | uid: stream.getUid(), 349 | channel: 'channel1', 350 | }) 351 | 352 | assert.lengthOf(transport.transport.receivedMessages, 2) 353 | assert.equal(transport.transport.receivedMessages[1].type, TransportMessageType.Unsubscribe) 354 | }) 355 | 356 | test('should broadcast to the bus when sending a message', async ({ assert }) => { 357 | const transport = makeTransport() 358 | const transmit = makeTransmitWithTransport(transport) 359 | makeTransmitWithTransport(transport) 360 | 361 | const stream = makeStream(transmit) 362 | 363 | await transmit.subscribe({ 364 | uid: stream.getUid(), 365 | channel: 'channel1', 366 | }) 367 | 368 | transmit.broadcast('channel1', { message: 'hello' }) 369 | 370 | assert.lengthOf(transport.transport.receivedMessages, 2) 371 | assert.equal(transport.transport.receivedMessages[1].type, TransportMessageType.Broadcast) 372 | }) 373 | 374 | test('second instance should receive the broadcasted message', async ({ assert }) => { 375 | const transport = makeTransport() 376 | const transmit = makeTransmitWithTransport(transport) 377 | const transmit2 = makeTransmitWithTransport(transport) 378 | 379 | const stream = makeStream(transmit) 380 | const stream2 = makeStream(transmit2) 381 | 382 | await transmit.subscribe({ 383 | uid: stream.getUid(), 384 | channel: 'channel1', 385 | }) 386 | 387 | await transmit2.subscribe({ 388 | uid: stream2.getUid(), 389 | channel: 'channel1', 390 | }) 391 | 392 | let dataReceived = false 393 | stream.on('data', () => { 394 | dataReceived = true 395 | }) 396 | 397 | transmit.broadcast('channel1', { message: 'hello' }) 398 | 399 | assert.isTrue(dataReceived) 400 | }) 401 | }) 402 | -------------------------------------------------------------------------------- /tests/utils.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @boringnode/transmit 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | import { test } from '@japa/runner' 9 | import { dataToString } from '../src/utils.js' 10 | 11 | test.group('Utils | dataToString', () => { 12 | test('should transform data when it is an object', ({ assert }) => { 13 | const value = dataToString({ name: 'Romain Lanz' }) 14 | 15 | assert.equal(value, 'data: {"name":"Romain Lanz"}\n') 16 | }) 17 | 18 | test('should transform data when it is a number', ({ assert }) => { 19 | const value = dataToString(42) 20 | 21 | assert.equal(value, 'data: 42\n') 22 | }) 23 | 24 | test('should transform data when it is a boolean', ({ assert }) => { 25 | const value = dataToString(true) 26 | 27 | assert.equal(value, 'data: true\n') 28 | }) 29 | 30 | test('should transform data when it is a string', ({ assert }) => { 31 | const value = dataToString('Hello world') 32 | 33 | assert.equal(value, 'data: Hello world\n') 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /transports.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @boringnode/transmit 3 | * 4 | * @license MIT 5 | * @copyright BoringNode 6 | */ 7 | 8 | export { redis } from '@boringnode/bus/transports/redis' 9 | 10 | export { mqtt } from '@boringnode/bus/transports/mqtt' 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@adonisjs/tsconfig/tsconfig.package.json", 3 | "compilerOptions": { 4 | "rootDir": "./", 5 | "outDir": "./build" 6 | } 7 | } 8 | --------------------------------------------------------------------------------