├── .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 |

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 |
--------------------------------------------------------------------------------