├── .editorconfig ├── .github ├── lock.yml ├── stale.yml └── workflows │ └── checks.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── LICENSE.md ├── README.md ├── bin └── test.ts ├── index.ts ├── package.json ├── src ├── hook.ts ├── hook_event.ts ├── http_client.ts ├── subscription.ts ├── subscription_status.ts ├── transmit.ts └── transmit_status.ts ├── test_utils ├── fake_event_source.ts └── fake_http_client.ts ├── tests ├── hook.spec.ts ├── http_client.spec.ts ├── subscription.spec.ts └── transmit.spec.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.json] 12 | insert_final_newline = ignore 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.github/lock.yml: -------------------------------------------------------------------------------- 1 | --- 2 | ignoreUnless: { { STALE_BOT } } 3 | --- 4 | # Configuration for Lock Threads - https://github.com/dessant/lock-threads-app 5 | 6 | # Number of days of inactivity before a closed issue or pull request is locked 7 | daysUntilLock: 60 8 | 9 | # Skip issues and pull requests created before a given timestamp. Timestamp must 10 | # follow ISO 8601 (`YYYY-MM-DD`). Set to `false` to disable 11 | skipCreatedBefore: false 12 | 13 | # Issues and pull requests with these labels will be ignored. Set to `[]` to disable 14 | exemptLabels: ['Type: Security'] 15 | 16 | # Label to add before locking, such as `outdated`. Set to `false` to disable 17 | lockLabel: false 18 | 19 | # Comment to post before locking. Set to `false` to disable 20 | lockComment: > 21 | This thread has been automatically locked since there has not been 22 | any recent activity after it was closed. Please open a new issue for 23 | related bugs. 24 | 25 | # Assign `resolved` as the reason for locking. Set to `false` to disable 26 | setLockReason: false 27 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | --- 2 | ignoreUnless: { { STALE_BOT } } 3 | --- 4 | # Number of days of inactivity before an issue becomes stale 5 | daysUntilStale: 60 6 | 7 | # Number of days of inactivity before a stale issue is closed 8 | daysUntilClose: 7 9 | 10 | # Issues with these labels will never be considered stale 11 | exemptLabels: 12 | - 'Type: Security' 13 | 14 | # Label to use when marking an issue as stale 15 | staleLabel: 'Status: Abandoned' 16 | 17 | # Comment to post when marking an issue as stale. Set to `false` to disable 18 | markComment: > 19 | This issue has been automatically marked as stale because it has not had 20 | recent activity. It will be closed if no further activity occurs. Thank you 21 | for your contributions. 22 | 23 | # Comment to post when closing a stale issue. Set to `false` to disable 24 | closeComment: false 25 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: checks 2 | on: 3 | - push 4 | - pull_request 5 | 6 | jobs: 7 | test: 8 | uses: adonisjs/.github/.github/workflows/test.yml@main 9 | with: 10 | disable-windows: true 11 | 12 | lint: 13 | uses: adonisjs/.github/.github/workflows/lint.yml@main 14 | 15 | typecheck: 16 | uses: adonisjs/.github/.github/workflows/typecheck.yml@main 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | coverage 4 | .idea 5 | .vscode 6 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | docs 3 | coverage 4 | *.html 5 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | 3 | Copyright (c) 2023, Romain Lanz, AdonisJS Core Team, 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 |

AdonisJS Transmit Client

3 |

A client for the native Server-Sent-Event (SSE) module of AdonisJS.

4 |
5 | 6 |
7 | 8 |
9 | 10 | [![gh-workflow-image]][gh-workflow-url] [![npm-image]][npm-url] ![][typescript-image] [![license-image]][license-url] [![synk-image]][synk-url] 11 | 12 |
13 | 14 |
15 |

16 | 17 | Usage 18 | 19 | | 20 | 21 | Checkout AdonisJS 22 | 23 |

24 |
25 | 26 |
27 | 28 |
29 | 30 | AdonisJS Transmit Client is a client for the native Server-Sent-Event (SSE) module of AdonisJS. It is built on top of the [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) API and provides a simple API to receive events from the server. 31 | 32 | 33 | 34 | ## Table of Contents 35 | 36 | - [Installation](#installation) 37 | - [Usage](#usage) 38 | - [Creating a subscription](#creating-a-subscription) 39 | - [Unsubscribing](#unsubscribing) 40 | - [Subscription Request](#subscription-request) 41 | - [Reconnecting](#reconnecting) 42 | - [Events](#events) 43 | 44 | 45 | 46 | ## Installation 47 | 48 | Install the package from the npm registry as follows: 49 | 50 | ```sh 51 | npm i @adonisjs/transmit-client 52 | ``` 53 | 54 | ## Usage 55 | 56 | The module exposes a `Transmit` class, which can be used to connect to the server and listen for events. 57 | 58 | ```ts 59 | import { Transmit } from '@adonisjs/transmit-client' 60 | 61 | const transmit = new Transmit({ 62 | baseUrl: 'http://localhost:3333', 63 | }) 64 | ``` 65 | 66 | ## Creating a subscription 67 | 68 | The `subscription` method is used to create a subscription to a channel. The method accepts the channel name 69 | 70 | ```ts 71 | const subscription = transmit.subscription('chat/1') 72 | ``` 73 | 74 | Then, you have to call the `create` method on the subscription to register it on the backend. 75 | 76 | ```ts 77 | await subscription.create() 78 | ``` 79 | 80 | You can listen for events on the channel using the `onMessage` method. You can define as many listeners as you want on the same subscription. 81 | 82 | ```ts 83 | subscription.onMessage((message) => { 84 | console.log(message) 85 | }) 86 | ``` 87 | 88 | You can also listen only once for a message using the `onMessagetOnce` method. 89 | 90 | ```ts 91 | subscription.onMessageOnce((message) => { 92 | console.log('I will be called only once') 93 | }) 94 | ``` 95 | 96 | Note listeners are local only; you can add them before or after registering your subscription on the server. 97 | 98 | ### Unsubscribing 99 | 100 | The `onMessage` method returns a function to remove the message handler from the subscription. 101 | 102 | ```ts 103 | const unsubscribe = subscription.onMessage(() => { 104 | console.log('message received!') 105 | }) 106 | 107 | // later 108 | unsubscribe() 109 | ``` 110 | 111 | If you want to entirely remove the subscription from the server, you can call the `delete` method. 112 | 113 | ```ts 114 | await subscription.delete() 115 | ``` 116 | 117 | ### Subscription Request 118 | 119 | You can alter the subscription request by using the `beforeSubscribe` or `beforeUnsubscribe` options. 120 | 121 | ```ts 122 | const transmit = new Transmit({ 123 | baseUrl: 'http://localhost:3333', 124 | beforeSubscribe: (_request: Request) => { 125 | console.log('beforeSubscribe') 126 | }, 127 | beforeUnsubscribe: (_request: Request) => { 128 | console.log('beforeUnsubscribe') 129 | }, 130 | }) 131 | ``` 132 | 133 | ### Reconnecting 134 | 135 | The transmit client will automatically reconnect to the server when the connection is lost. You can change the number of retries and hook into the reconnect lifecycle as follows: 136 | 137 | ```ts 138 | const transmit = new Transmit({ 139 | baseUrl: 'http://localhost:3333', 140 | maxReconnectionAttempts: 5, 141 | onReconnectAttempt: (attempt) => { 142 | console.log('Reconnect attempt ' + attempt) 143 | }, 144 | onReconnectFailed: () => { 145 | console.log('Reconnect failed') 146 | }, 147 | }) 148 | ``` 149 | 150 | # Events 151 | 152 | The`Transmit` class uses the [`EventTarget`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget) class to emits multiple events. 153 | 154 | ```ts 155 | transmit.on('connected', () => { 156 | console.log('connected') 157 | }) 158 | 159 | transmit.on('disconnected', () => { 160 | console.log('disconnected') 161 | }) 162 | 163 | transmit.on('reconnecting', () => { 164 | console.log('reconnecting') 165 | }) 166 | ``` 167 | 168 | [gh-workflow-image]: https://img.shields.io/github/actions/workflow/status/adonisjs/transmit-client/test?style=for-the-badge 169 | [gh-workflow-url]: https://github.com/adonisjs/transmit-client/actions/workflows/test.yml "Github action" 170 | 171 | [typescript-image]: https://img.shields.io/badge/Typescript-294E80.svg?style=for-the-badge&logo=typescript 172 | [typescript-url]: "typescript" 173 | 174 | [npm-image]: https://img.shields.io/npm/v/@adonisjs/transmit-client.svg?style=for-the-badge&logo=npm 175 | [npm-url]: https://npmjs.org/package/@adonisjs/transmit-client 'npm' 176 | 177 | [license-image]: https://img.shields.io/npm/l/@adonisjs/transmit-client?color=blueviolet&style=for-the-badge 178 | [license-url]: LICENSE.md 'license' 179 | 180 | [synk-image]: https://img.shields.io/snyk/vulnerabilities/github/adonisjs/transmit-client?label=Synk%20Vulnerabilities&style=for-the-badge 181 | [synk-url]: https://snyk.io/test/github/adonisjs/transmit-client?targetFile=package.json "synk" 182 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/transmit-client 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | export { Transmit } from './src/transmit.js' 11 | export { Subscription } from './src/subscription.js' 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@adonisjs/transmit-client", 3 | "version": "1.0.0", 4 | "description": "A client for the native Server-Sent-Event module of AdonisJS.", 5 | "keywords": [ 6 | "client", 7 | "sse-client", 8 | "sse", 9 | "server-sent-event", 10 | "adonis", 11 | "adonisjs", 12 | "adonis-framework" 13 | ], 14 | "license": "MIT", 15 | "author": "Romain Lanz ", 16 | "type": "module", 17 | "source": "index.ts", 18 | "main": "./build/index.js", 19 | "types": "./build/index.d.ts", 20 | "scripts": { 21 | "clean": "del-cli build", 22 | "typecheck": "tsc --noEmit", 23 | "lint": "eslint . --ext=.ts", 24 | "format": "prettier --write .", 25 | "build": "tsup", 26 | "release": "npx release-it", 27 | "version": "npm run build", 28 | "prepublishOnly": "npm run build", 29 | "test": "c8 node --loader ts-node/esm --enable-source-maps bin/test.ts" 30 | }, 31 | "devDependencies": { 32 | "@adonisjs/eslint-config": "^1.1.7", 33 | "@adonisjs/prettier-config": "^1.1.7", 34 | "@adonisjs/tsconfig": "^1.3.0", 35 | "@japa/assert": "^2.0.0-2", 36 | "@japa/runner": "^3.0.0-9", 37 | "@swc/core": "^1.4.11", 38 | "c8": "^9.1.0", 39 | "del-cli": "^5.0.0", 40 | "eslint": "^8.44.0", 41 | "prettier": "^3.0.0", 42 | "release-it": "^17.1.1", 43 | "ts-node": "^10.9.2", 44 | "tsup": "^8.0.2", 45 | "typescript": "^5.1.6" 46 | }, 47 | "files": [ 48 | "src", 49 | "build" 50 | ], 51 | "engines": { 52 | "node": ">=18.16.0" 53 | }, 54 | "eslintConfig": { 55 | "extends": "@adonisjs/eslint-config/package" 56 | }, 57 | "prettier": "@adonisjs/prettier-config", 58 | "publishConfig": { 59 | "access": "public", 60 | "tag": "latest" 61 | }, 62 | "tsup": { 63 | "dts": true, 64 | "clean": true, 65 | "format": "esm", 66 | "sourceMap": true, 67 | "target": "es2020", 68 | "outDir": "build", 69 | "entry": [ 70 | "index.ts" 71 | ] 72 | }, 73 | "release-it": { 74 | "git": { 75 | "commitMessage": "chore(release): ${version}", 76 | "tagAnnotation": "v${version}", 77 | "tagName": "v${version}" 78 | }, 79 | "github": { 80 | "release": true, 81 | "releaseName": "v${version}", 82 | "web": true 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/hook.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/transmit-client 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { HookEvent } from './hook_event.js' 11 | 12 | export class Hook { 13 | #handlers = new Map void>>() 14 | 15 | register(event: HookEvent, handler: (...args: any[]) => void) { 16 | if (!this.#handlers.has(event)) { 17 | this.#handlers.set(event, new Set()) 18 | } 19 | 20 | this.#handlers.get(event)?.add(handler) 21 | 22 | return this 23 | } 24 | 25 | beforeSubscribe(request: Request) { 26 | this.#handlers.get(HookEvent.BeforeSubscribe)?.forEach((handler) => handler(request)) 27 | 28 | return this 29 | } 30 | 31 | beforeUnsubscribe(request: Request) { 32 | this.#handlers.get(HookEvent.BeforeUnsubscribe)?.forEach((handler) => handler(request)) 33 | 34 | return this 35 | } 36 | 37 | onReconnectAttempt(attempt: number) { 38 | this.#handlers.get(HookEvent.OnReconnectAttempt)?.forEach((handler) => handler(attempt)) 39 | 40 | return this 41 | } 42 | 43 | onReconnectFailed() { 44 | this.#handlers.get(HookEvent.OnReconnectFailed)?.forEach((handler) => handler()) 45 | 46 | return this 47 | } 48 | 49 | onSubscribeFailed(response: Response) { 50 | this.#handlers.get(HookEvent.OnSubscribeFailed)?.forEach((handler) => handler(response)) 51 | 52 | return this 53 | } 54 | 55 | onSubscription(channel: string) { 56 | this.#handlers.get(HookEvent.OnSubscription)?.forEach((handler) => handler(channel)) 57 | 58 | return this 59 | } 60 | 61 | onUnsubscription(channel: string) { 62 | this.#handlers.get(HookEvent.OnUnsubscription)?.forEach((handler) => handler(channel)) 63 | 64 | return this 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/hook_event.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/transmit-client 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | export const HookEvent = { 11 | BeforeSubscribe: 'beforeSubscribe', 12 | BeforeUnsubscribe: 'beforeUnsubscribe', 13 | OnReconnectAttempt: 'onReconnectAttempt', 14 | OnReconnectFailed: 'onReconnectFailed', 15 | OnSubscribeFailed: 'onSubscribeFailed', 16 | OnSubscription: 'onSubscription', 17 | OnUnsubscription: 'onUnsubscription', 18 | } as const 19 | 20 | export type HookEvent = (typeof HookEvent)[keyof typeof HookEvent] 21 | -------------------------------------------------------------------------------- /src/http_client.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/transmit-client 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | interface HttpClientOptions { 11 | baseUrl: string 12 | uid: string 13 | } 14 | 15 | export class HttpClient { 16 | #options: HttpClientOptions 17 | 18 | constructor(options: HttpClientOptions) { 19 | this.#options = options 20 | } 21 | 22 | send(request: Request) { 23 | return fetch(request) 24 | } 25 | 26 | createRequest(path: string, body: Record) { 27 | return new Request(`${this.#options.baseUrl}${path}`, { 28 | method: 'POST', 29 | headers: { 30 | 'Content-Type': 'application/json', 31 | 'X-XSRF-TOKEN': this.#retrieveXsrfToken() ?? '', 32 | }, 33 | body: JSON.stringify({ uid: this.#options.uid, ...body }), 34 | credentials: 'include', 35 | }) 36 | } 37 | 38 | #retrieveXsrfToken() { 39 | //? This is a browser-only feature 40 | if (typeof document === 'undefined') return null 41 | 42 | const match = document.cookie.match(new RegExp('(^|;\\s*)(XSRF-TOKEN)=([^;]*)')) 43 | 44 | return match ? decodeURIComponent(match[3]) : null 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/subscription.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/transmit-client 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { SubscriptionStatus } from './subscription_status.js' 11 | import { HttpClient } from './http_client.js' 12 | import { Hook } from './hook.js' 13 | import { TransmitStatus } from './transmit_status.js' 14 | 15 | interface SubscriptionOptions { 16 | channel: string 17 | httpClient: HttpClient 18 | getEventSourceStatus: () => TransmitStatus 19 | hooks?: Hook 20 | } 21 | 22 | export class Subscription { 23 | /** 24 | * HTTP client instance. 25 | */ 26 | #httpClient: HttpClient 27 | 28 | /** 29 | * Hook instance. 30 | */ 31 | #hooks: Hook | undefined 32 | 33 | /** 34 | * Channel name. 35 | */ 36 | #channel: string 37 | 38 | /** 39 | * Event source status getter. 40 | */ 41 | #getEventSourceStatus: () => TransmitStatus 42 | 43 | /** 44 | * Registered message handlers. 45 | */ 46 | #handlers = new Set<(message: any) => void>() 47 | 48 | /** 49 | * Current status of the subscription. 50 | */ 51 | #status: SubscriptionStatus = SubscriptionStatus.Pending 52 | 53 | /** 54 | * Returns if the subscription is created or not. 55 | */ 56 | get isCreated() { 57 | return this.#status === SubscriptionStatus.Created 58 | } 59 | 60 | /** 61 | * Returns if the subscription is deleted or not. 62 | */ 63 | get isDeleted() { 64 | return this.#status === SubscriptionStatus.Deleted 65 | } 66 | 67 | /** 68 | * Returns the number of registered handlers. 69 | */ 70 | get handlerCount() { 71 | return this.#handlers.size 72 | } 73 | 74 | constructor(options: SubscriptionOptions) { 75 | this.#channel = options.channel 76 | this.#httpClient = options.httpClient 77 | this.#hooks = options.hooks 78 | this.#getEventSourceStatus = options.getEventSourceStatus 79 | } 80 | 81 | /** 82 | * Run all registered handlers for the subscription. 83 | */ 84 | $runHandler(message: unknown) { 85 | for (const handler of this.#handlers) { 86 | handler(message) 87 | } 88 | } 89 | 90 | async create() { 91 | if (this.isCreated) { 92 | return 93 | } 94 | 95 | return this.forceCreate() 96 | } 97 | 98 | async forceCreate() { 99 | if (this.#getEventSourceStatus() !== TransmitStatus.Connected) { 100 | return new Promise((resolve) => { 101 | setTimeout(() => { 102 | resolve(this.create()) 103 | }, 100) 104 | }) 105 | } 106 | 107 | const request = this.#httpClient.createRequest('/__transmit/subscribe', { 108 | channel: this.#channel, 109 | }) 110 | 111 | this.#hooks?.beforeSubscribe(request) 112 | 113 | try { 114 | const response = await this.#httpClient.send(request) 115 | 116 | //? Dump the response text 117 | void response.text() 118 | 119 | if (!response.ok) { 120 | this.#hooks?.onSubscribeFailed(response) 121 | return 122 | } 123 | 124 | this.#status = SubscriptionStatus.Created 125 | this.#hooks?.onSubscription(this.#channel) 126 | } catch (error) {} 127 | } 128 | 129 | async delete() { 130 | if (this.isDeleted || !this.isCreated) { 131 | return 132 | } 133 | 134 | const request = this.#httpClient.createRequest('/__transmit/unsubscribe', { 135 | channel: this.#channel, 136 | }) 137 | 138 | this.#hooks?.beforeUnsubscribe(request) 139 | 140 | try { 141 | const response = await this.#httpClient.send(request) 142 | 143 | //? Dump the response text 144 | void response.text() 145 | 146 | if (!response.ok) { 147 | return 148 | } 149 | 150 | this.#status = SubscriptionStatus.Deleted 151 | this.#hooks?.onUnsubscription(this.#channel) 152 | } catch (error) {} 153 | } 154 | 155 | onMessage(handler: (message: T) => void) { 156 | this.#handlers.add(handler) 157 | 158 | return () => { 159 | this.#handlers.delete(handler) 160 | } 161 | } 162 | 163 | onMessageOnce(handler: (message: T) => void) { 164 | const deleteHandler = this.onMessage((message) => { 165 | handler(message) 166 | deleteHandler() 167 | }) 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/subscription_status.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/transmit-client 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | export const SubscriptionStatus = { 11 | Pending: 0, 12 | Created: 1, 13 | Deleted: 2, 14 | } as const 15 | 16 | export type SubscriptionStatus = (typeof SubscriptionStatus)[keyof typeof SubscriptionStatus] 17 | -------------------------------------------------------------------------------- /src/transmit.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/transmit-client 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { Subscription } from './subscription.js' 11 | import { HttpClient } from './http_client.js' 12 | import { TransmitStatus } from './transmit_status.js' 13 | import { Hook } from './hook.js' 14 | import { HookEvent } from './hook_event.js' 15 | 16 | interface TransmitOptions { 17 | baseUrl: string 18 | uidGenerator?: () => string 19 | eventSourceFactory?: (url: string | URL, options: { withCredentials: boolean }) => EventSource 20 | eventTargetFactory?: () => EventTarget | null 21 | httpClientFactory?: (baseUrl: string, uid: string) => HttpClient 22 | beforeSubscribe?: (request: RequestInit) => void 23 | beforeUnsubscribe?: (request: RequestInit) => void 24 | maxReconnectAttempts?: number 25 | onReconnectAttempt?: (attempt: number) => void 26 | onReconnectFailed?: () => void 27 | onSubscribeFailed?: (response: Response) => void 28 | onSubscription?: (channel: string) => void 29 | onUnsubscription?: (channel: string) => void 30 | } 31 | 32 | export class Transmit { 33 | /** 34 | * Unique identifier for this client. 35 | */ 36 | #uid: string 37 | 38 | /** 39 | * Options for this client. 40 | */ 41 | #options: TransmitOptions 42 | 43 | /** 44 | * Registered subscriptions. 45 | */ 46 | #subscriptions = new Map() 47 | 48 | /** 49 | * HTTP client instance. 50 | */ 51 | #httpClient: HttpClient 52 | 53 | /** 54 | * Hook instance. 55 | */ 56 | #hooks: Hook 57 | 58 | /** 59 | * Current status of the client. 60 | */ 61 | #status: TransmitStatus = TransmitStatus.Initializing 62 | 63 | /** 64 | * EventSource instance. 65 | */ 66 | #eventSource: EventSource | undefined 67 | 68 | /** 69 | * EventTarget instance. 70 | */ 71 | #eventTarget: EventTarget | null 72 | 73 | /** 74 | * Number of reconnect attempts. 75 | */ 76 | #reconnectAttempts: number = 0 77 | 78 | /** 79 | * Returns the unique identifier of the client. 80 | */ 81 | get uid() { 82 | return this.#uid 83 | } 84 | 85 | constructor(options: TransmitOptions) { 86 | if (typeof options.uidGenerator === 'undefined') { 87 | options.uidGenerator = () => crypto.randomUUID() 88 | } 89 | 90 | if (typeof options.eventSourceFactory === 'undefined') { 91 | options.eventSourceFactory = (...args) => new EventSource(...args) 92 | } 93 | 94 | if (typeof options.eventTargetFactory === 'undefined') { 95 | options.eventTargetFactory = () => new EventTarget() 96 | } 97 | 98 | if (typeof options.httpClientFactory === 'undefined') { 99 | options.httpClientFactory = (baseUrl, uid) => new HttpClient({ baseUrl, uid }) 100 | } 101 | 102 | if (typeof options.maxReconnectAttempts === 'undefined') { 103 | options.maxReconnectAttempts = 5 104 | } 105 | 106 | this.#uid = options.uidGenerator() 107 | this.#eventTarget = options.eventTargetFactory() 108 | this.#hooks = new Hook() 109 | this.#httpClient = options.httpClientFactory(options.baseUrl, this.#uid) 110 | 111 | if (options.beforeSubscribe) { 112 | this.#hooks.register(HookEvent.BeforeSubscribe, options.beforeSubscribe) 113 | } 114 | 115 | if (options.beforeUnsubscribe) { 116 | this.#hooks.register(HookEvent.BeforeUnsubscribe, options.beforeUnsubscribe) 117 | } 118 | 119 | if (options.onReconnectAttempt) { 120 | this.#hooks.register(HookEvent.OnReconnectAttempt, options.onReconnectAttempt) 121 | } 122 | 123 | if (options.onReconnectFailed) { 124 | this.#hooks.register(HookEvent.OnReconnectFailed, options.onReconnectFailed) 125 | } 126 | 127 | if (options.onSubscribeFailed) { 128 | this.#hooks.register(HookEvent.OnSubscribeFailed, options.onSubscribeFailed) 129 | } 130 | 131 | if (options.onSubscription) { 132 | this.#hooks.register(HookEvent.OnSubscription, options.onSubscription) 133 | } 134 | 135 | if (options.onUnsubscription) { 136 | this.#hooks.register(HookEvent.OnUnsubscription, options.onUnsubscription) 137 | } 138 | 139 | this.#options = options 140 | this.#connect() 141 | } 142 | 143 | #changeStatus(status: TransmitStatus) { 144 | this.#status = status 145 | this.#eventTarget?.dispatchEvent(new CustomEvent(status)) 146 | } 147 | 148 | #connect() { 149 | this.#changeStatus(TransmitStatus.Connecting) 150 | 151 | const url = new URL(`${this.#options.baseUrl}/__transmit/events`) 152 | url.searchParams.append('uid', this.#uid) 153 | 154 | this.#eventSource = this.#options.eventSourceFactory!(url, { 155 | withCredentials: true, 156 | }) 157 | 158 | this.#eventSource.addEventListener('message', this.#onMessage.bind(this)) 159 | this.#eventSource.addEventListener('error', this.#onError.bind(this)) 160 | this.#eventSource.addEventListener('open', () => { 161 | this.#changeStatus(TransmitStatus.Connected) 162 | this.#reconnectAttempts = 0 163 | 164 | for (const subscription of this.#subscriptions.values()) { 165 | if (subscription.isCreated) { 166 | void subscription.forceCreate() 167 | } 168 | } 169 | }) 170 | } 171 | 172 | #onMessage(event: MessageEvent) { 173 | const data = JSON.parse(event.data) 174 | const subscription = this.#subscriptions.get(data.channel) 175 | 176 | if (typeof subscription === 'undefined') { 177 | return 178 | } 179 | 180 | try { 181 | subscription.$runHandler(data.payload) 182 | } catch (error) { 183 | // TODO: Rescue 184 | console.log(error) 185 | } 186 | } 187 | 188 | #onError() { 189 | if (this.#status !== TransmitStatus.Reconnecting) { 190 | this.#changeStatus(TransmitStatus.Disconnected) 191 | } 192 | 193 | this.#changeStatus(TransmitStatus.Reconnecting) 194 | 195 | this.#hooks.onReconnectAttempt(this.#reconnectAttempts + 1) 196 | 197 | if ( 198 | this.#options.maxReconnectAttempts && 199 | this.#reconnectAttempts >= this.#options.maxReconnectAttempts 200 | ) { 201 | this.#eventSource!.close() 202 | 203 | this.#hooks.onReconnectFailed() 204 | 205 | return 206 | } 207 | 208 | this.#reconnectAttempts++ 209 | } 210 | 211 | subscription(channel: string) { 212 | const subscription = new Subscription({ 213 | channel, 214 | httpClient: this.#httpClient, 215 | hooks: this.#hooks, 216 | getEventSourceStatus: () => this.#status, 217 | }) 218 | 219 | if (this.#subscriptions.has(channel)) { 220 | return this.#subscriptions.get(channel)! 221 | } 222 | 223 | this.#subscriptions.set(channel, subscription) 224 | 225 | return subscription 226 | } 227 | 228 | on(event: Exclude, callback: (event: CustomEvent) => void) { 229 | // @ts-ignore 230 | this.#eventTarget?.addEventListener(event, callback) 231 | } 232 | 233 | close() { 234 | this.#eventSource?.close() 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /src/transmit_status.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/transmit-client 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | export const TransmitStatus = { 11 | Initializing: 'initializing', 12 | Connecting: 'connecting', 13 | Connected: 'connected', 14 | Disconnected: 'disconnected', 15 | Reconnecting: 'reconnecting', 16 | } as const 17 | 18 | export type TransmitStatus = (typeof TransmitStatus)[keyof typeof TransmitStatus] 19 | -------------------------------------------------------------------------------- /test_utils/fake_event_source.ts: -------------------------------------------------------------------------------- 1 | export class FakeEventSource { 2 | hadError = false 3 | hadMessage = false 4 | isOpen = false 5 | constructorOptions: { url: string | URL; withCredentials: boolean } 6 | listeners: { [type: string]: ((this: EventSource, event: MessageEvent) => any)[] } = {} 7 | 8 | constructor(url: string | URL, withCredentials: boolean = false) { 9 | this.constructorOptions = { url, withCredentials } 10 | 11 | // Simulate the EventSource opening 12 | setTimeout(() => { 13 | this.emit('open', new MessageEvent('open')) 14 | }, 0) 15 | } 16 | 17 | onerror(): any { 18 | this.hadError = true 19 | } 20 | 21 | onmessage(): any { 22 | this.hadMessage = true 23 | } 24 | 25 | onopen(): any { 26 | this.isOpen = true 27 | } 28 | 29 | addEventListener(type: string, listener: (this: EventSource, event: MessageEvent) => any) { 30 | if (!this.listeners[type]) { 31 | this.listeners[type] = [] 32 | } 33 | 34 | this.listeners[type].push(listener) 35 | } 36 | 37 | emit(type: string, event: MessageEvent) { 38 | // @ts-expect-error - We know this is a valid type 39 | this.listeners[type]?.forEach((listener) => listener.call(this, event)) 40 | } 41 | 42 | sendOpenEvent() { 43 | this.emit('open', new MessageEvent('open')) 44 | } 45 | 46 | sendCloseEvent() { 47 | this.emit('close', new MessageEvent('close')) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /test_utils/fake_http_client.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '../src/http_client.js' 2 | 3 | export class FakeHttpClient extends HttpClient { 4 | sentRequests: Request[] = [] 5 | 6 | async send(request: Request) { 7 | this.sentRequests.push(request) 8 | 9 | return new Response() 10 | } 11 | 12 | reset() { 13 | this.sentRequests = [] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tests/hook.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/transmit-client 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { Hook } from '../src/hook.js' 12 | import { HookEvent } from '../src/hook_event.js' 13 | 14 | test.group('Hook', () => { 15 | test('should register a handler for {$self} event') 16 | .with([ 17 | HookEvent.BeforeSubscribe, 18 | HookEvent.BeforeUnsubscribe, 19 | HookEvent.OnReconnectAttempt, 20 | HookEvent.OnSubscribeFailed, 21 | HookEvent.OnSubscription, 22 | HookEvent.OnUnsubscription, 23 | ]) 24 | .run(({ assert }, event) => { 25 | assert.plan(1) 26 | 27 | const hook = new Hook() 28 | 29 | hook.register(event, (payload) => { 30 | assert.equal(payload, 1) 31 | }) 32 | 33 | // @ts-expect-error 34 | hook[event](1) 35 | }) 36 | 37 | test('should register multiple handlers for {$self} event') 38 | .with([ 39 | HookEvent.BeforeSubscribe, 40 | HookEvent.BeforeUnsubscribe, 41 | HookEvent.OnReconnectAttempt, 42 | HookEvent.OnSubscribeFailed, 43 | HookEvent.OnSubscription, 44 | HookEvent.OnUnsubscription, 45 | ]) 46 | .run(({ assert }, event: HookEvent) => { 47 | assert.plan(2) 48 | 49 | const hook = new Hook() 50 | 51 | hook.register(event, (payload) => { 52 | assert.equal(payload, 1) 53 | }) 54 | 55 | hook.register(event, (payload) => { 56 | assert.equal(payload, 1) 57 | }) 58 | 59 | // @ts-expect-error 60 | hook[event](1) 61 | }) 62 | 63 | test('should register a handler for {$self} event') 64 | .with([HookEvent.OnReconnectFailed]) 65 | .run(({ assert }, event) => { 66 | assert.plan(1) 67 | 68 | const hook = new Hook() 69 | 70 | hook.register(event, () => { 71 | assert.isTrue(true) 72 | }) 73 | 74 | hook[event]() 75 | }) 76 | 77 | test('should register multiple handlers for {$self} event') 78 | .with([HookEvent.OnReconnectFailed]) 79 | .run(({ assert }, event) => { 80 | assert.plan(2) 81 | 82 | const hook = new Hook() 83 | 84 | hook.register(event, () => { 85 | assert.isTrue(true) 86 | }) 87 | 88 | hook.register(event, () => { 89 | assert.isTrue(true) 90 | }) 91 | 92 | hook[event]() 93 | }) 94 | 95 | test('should not throw error no handler are defined', () => { 96 | const hook = new Hook() 97 | 98 | hook.beforeSubscribe(new Request('http://localhost')) 99 | }) 100 | }) 101 | -------------------------------------------------------------------------------- /tests/http_client.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/transmit-client 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { test } from '@japa/runner' 11 | import { HttpClient } from '../src/http_client.js' 12 | 13 | test.group('HttpClient', () => { 14 | test('should create a request instance', ({ assert }) => { 15 | const client = new HttpClient({ 16 | baseUrl: 'http://localhost', 17 | uid: '1', 18 | }) 19 | 20 | const request = client.createRequest('/test', { foo: 'bar' }) 21 | 22 | assert.equal(request.url, 'http://localhost/test') 23 | assert.equal(request.method, 'POST') 24 | assert.equal(request.headers.get('Content-Type'), 'application/json') 25 | assert.equal(request.headers.get('X-XSRF-TOKEN'), '') 26 | assert.equal(request.credentials, 'include') 27 | }) 28 | 29 | test('should retrieve XSRF token from cookies', ({ assert }) => { 30 | // @ts-expect-error 31 | globalThis.document = { 32 | cookie: 'XSRF-TOKEN=1234', 33 | } 34 | 35 | const client = new HttpClient({ 36 | baseUrl: 'http://localhost', 37 | uid: '1', 38 | }) 39 | 40 | const request = client.createRequest('/test', { foo: 'bar' }) 41 | 42 | assert.equal(request.headers.get('X-XSRF-TOKEN'), '1234') 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /tests/subscription.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/transmit-client 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { setTimeout } from 'node:timers/promises' 11 | import { test } from '@japa/runner' 12 | import { Hook } from '../src/hook.js' 13 | import { Subscription } from '../src/subscription.js' 14 | import { FakeHttpClient } from '../test_utils/fake_http_client.js' 15 | import { TransmitStatus } from '../src/transmit_status.js' 16 | 17 | const client = new FakeHttpClient({ 18 | baseUrl: 'http://localhost', 19 | uid: '1', 20 | }) 21 | 22 | const hook = new Hook() 23 | 24 | function subscriptionFactory(statusFactory?: () => TransmitStatus) { 25 | return new Subscription({ 26 | channel: 'foo', 27 | httpClient: client, 28 | hooks: hook, 29 | getEventSourceStatus: () => statusFactory?.() ?? 'connected', 30 | }) 31 | } 32 | 33 | test.group('Subscription', (group) => { 34 | group.each.teardown(() => client.reset()) 35 | 36 | test('should be pending by default', ({ assert }) => { 37 | const subscription = subscriptionFactory() 38 | 39 | assert.isFalse(subscription.isCreated) 40 | assert.isFalse(subscription.isDeleted) 41 | }) 42 | 43 | test('should create a subscription', async ({ assert }) => { 44 | const subscription = subscriptionFactory() 45 | 46 | await subscription.create() 47 | 48 | assert.isTrue(subscription.isCreated) 49 | assert.lengthOf(client.sentRequests, 1) 50 | }) 51 | 52 | test('should not create a subscription when already created', async ({ assert }) => { 53 | const subscription = subscriptionFactory() 54 | 55 | await subscription.create() 56 | await subscription.create() 57 | 58 | assert.isTrue(subscription.isCreated) 59 | assert.lengthOf(client.sentRequests, 1) 60 | }) 61 | 62 | test('should not create a subscription when event source is not connected', async ({ 63 | assert, 64 | }) => { 65 | let status: TransmitStatus = TransmitStatus.Connecting 66 | const subscription = subscriptionFactory(() => status) 67 | 68 | void subscription.create() 69 | 70 | //? Waiting for the request to be sent 71 | await setTimeout(500) 72 | 73 | assert.isFalse(subscription.isCreated) 74 | assert.lengthOf(client.sentRequests, 0) 75 | 76 | //? Changing the status to connected to avoid setTimeout loop 77 | status = TransmitStatus.Connected 78 | }) 79 | 80 | test('should delete a subscription', async ({ assert }) => { 81 | const subscription = subscriptionFactory() 82 | 83 | await subscription.create() 84 | 85 | assert.isTrue(subscription.isCreated) 86 | assert.lengthOf(client.sentRequests, 1) 87 | 88 | await subscription.delete() 89 | 90 | assert.isTrue(subscription.isDeleted) 91 | assert.lengthOf(client.sentRequests, 2) 92 | }) 93 | 94 | test('should not delete a subscription when already deleted', async ({ assert }) => { 95 | const subscription = subscriptionFactory() 96 | 97 | await subscription.create() 98 | 99 | assert.isTrue(subscription.isCreated) 100 | assert.lengthOf(client.sentRequests, 1) 101 | 102 | await subscription.delete() 103 | 104 | assert.isTrue(subscription.isDeleted) 105 | assert.lengthOf(client.sentRequests, 2) 106 | 107 | await subscription.delete() 108 | 109 | assert.lengthOf(client.sentRequests, 2) 110 | }) 111 | 112 | test('should not delete a subscription when not created', async ({ assert }) => { 113 | const subscription = subscriptionFactory() 114 | 115 | await subscription.delete() 116 | 117 | assert.isFalse(subscription.isDeleted) 118 | assert.lengthOf(client.sentRequests, 0) 119 | }) 120 | 121 | test('should register a handler', async ({ assert }) => { 122 | assert.plan(1) 123 | 124 | const subscription = subscriptionFactory() 125 | 126 | subscription.onMessage(() => { 127 | assert.isTrue(true) 128 | }) 129 | 130 | subscription.$runHandler(null) 131 | }) 132 | 133 | test('should run all registered handlers', async ({ assert }) => { 134 | assert.plan(2) 135 | 136 | const subscription = subscriptionFactory() 137 | 138 | subscription.onMessage((payload) => { 139 | assert.equal(payload, 1) 140 | }) 141 | 142 | subscription.onMessage((payload) => { 143 | assert.equal(payload, 1) 144 | }) 145 | 146 | subscription.$runHandler(1) 147 | }) 148 | 149 | test('should run only once some handler', async ({ assert }) => { 150 | assert.plan(1) 151 | 152 | const subscription = subscriptionFactory() 153 | 154 | subscription.onMessageOnce(() => { 155 | assert.isTrue(true) 156 | }) 157 | 158 | subscription.$runHandler(null) 159 | subscription.$runHandler(null) 160 | }) 161 | 162 | test('should get the number of registered handlers', async ({ assert }) => { 163 | const subscription = subscriptionFactory() 164 | 165 | subscription.onMessage(() => {}) 166 | subscription.onMessage(() => {}) 167 | 168 | assert.equal(subscription.handlerCount, 2) 169 | }) 170 | }) 171 | -------------------------------------------------------------------------------- /tests/transmit.spec.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @adonisjs/transmit-client 3 | * 4 | * (c) AdonisJS 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | import { setTimeout } from 'node:timers/promises' 11 | import { test } from '@japa/runner' 12 | import { Transmit } from '../src/transmit.js' 13 | import { FakeEventSource } from '../test_utils/fake_event_source.js' 14 | import { Subscription } from '../src/subscription.js' 15 | import { FakeHttpClient } from '../test_utils/fake_http_client.js' 16 | 17 | test.group('Transmit', () => { 18 | test('should connect to the server', ({ assert }) => { 19 | let eventSource: FakeEventSource | null = null 20 | 21 | new Transmit({ 22 | baseUrl: 'http://localhost', 23 | uidGenerator: () => '1', 24 | // @ts-expect-error - Mock is not 1:1 with EventSource 25 | eventSourceFactory(url, options) { 26 | eventSource = new FakeEventSource(url, options.withCredentials) 27 | return eventSource 28 | }, 29 | }) 30 | 31 | assert.isDefined(eventSource) 32 | assert.equal(eventSource!.constructorOptions.url, 'http://localhost/__transmit/events?uid=1') 33 | assert.isTrue(eventSource!.constructorOptions.withCredentials) 34 | }) 35 | 36 | test('should allow to create subscription', ({ assert }) => { 37 | const transmit = new Transmit({ 38 | baseUrl: 'http://localhost', 39 | uidGenerator: () => '1', 40 | // @ts-expect-error - Mock is not 1:1 with EventSource 41 | eventSourceFactory(url, options) { 42 | return new FakeEventSource(url, options.withCredentials) 43 | }, 44 | }) 45 | 46 | const subscription = transmit.subscription('channel') 47 | 48 | assert.instanceOf(subscription, Subscription) 49 | }) 50 | 51 | test('should allow to customize the uid generator', ({ assert }) => { 52 | const transmit = new Transmit({ 53 | baseUrl: 'http://localhost', 54 | uidGenerator: () => 'custom-uid', 55 | // @ts-expect-error - Mock is not 1:1 with EventSource 56 | eventSourceFactory(url, options) { 57 | return new FakeEventSource(url, options.withCredentials) 58 | }, 59 | }) 60 | 61 | assert.equal(transmit.uid, 'custom-uid') 62 | }) 63 | 64 | test('should compute uuid when uid generator is not defined', ({ assert }) => { 65 | const transmit = new Transmit({ 66 | baseUrl: 'http://localhost', 67 | // @ts-expect-error - Mock is not 1:1 with EventSource 68 | eventSourceFactory(url, options) { 69 | return new FakeEventSource(url, options.withCredentials) 70 | }, 71 | }) 72 | 73 | assert.isString(transmit.uid) 74 | }) 75 | 76 | test('should dispatch messages to the subscriptions', async ({ assert }) => { 77 | assert.plan(1) 78 | 79 | let eventSource: FakeEventSource | null = null 80 | 81 | const transmit = new Transmit({ 82 | baseUrl: 'http://localhost', 83 | uidGenerator: () => '1', 84 | // @ts-expect-error - Mock is not 1:1 with EventSource 85 | eventSourceFactory(url, options) { 86 | eventSource = new FakeEventSource(url, options.withCredentials) 87 | return eventSource 88 | }, 89 | }) 90 | 91 | const subscription = transmit.subscription('channel') 92 | 93 | subscription.onMessage((payload) => { 94 | assert.equal(payload, 'hello') 95 | }) 96 | 97 | // @ts-expect-error - Message is not 1:1 with MessageEvent 98 | eventSource!.emit('message', { data: JSON.stringify({ channel: 'channel', payload: 'hello' }) }) 99 | }) 100 | 101 | test('should not register subscription if they are not created on connection failure', async ({ 102 | assert, 103 | }) => { 104 | let eventSource: FakeEventSource | null = null 105 | let httpClient: FakeHttpClient | null = null 106 | 107 | const transmit = new Transmit({ 108 | baseUrl: 'http://localhost', 109 | uidGenerator: () => '1', 110 | // @ts-expect-error - Mock is not 1:1 with EventSource 111 | eventSourceFactory(url, options) { 112 | eventSource = new FakeEventSource(url, options.withCredentials) 113 | return eventSource 114 | }, 115 | httpClientFactory(baseUrl, uid) { 116 | httpClient = new FakeHttpClient({ baseUrl, uid }) 117 | return httpClient 118 | }, 119 | }) 120 | 121 | transmit.subscription('channel1') 122 | transmit.subscription('channel2') 123 | 124 | // Simulate latency 125 | await setTimeout(100) 126 | 127 | assert.equal(httpClient!.sentRequests.length, 0) 128 | 129 | eventSource!.sendCloseEvent() 130 | eventSource!.sendOpenEvent() 131 | 132 | assert.equal(httpClient!.sentRequests.length, 0) 133 | }) 134 | 135 | test('should re-connect only created subscription', async ({ assert }) => { 136 | let eventSource: FakeEventSource | null = null 137 | let httpClient: FakeHttpClient | null = null 138 | 139 | const transmit = new Transmit({ 140 | baseUrl: 'http://localhost', 141 | uidGenerator: () => '1', 142 | // @ts-expect-error - Mock is not 1:1 with EventSource 143 | eventSourceFactory(url, options) { 144 | eventSource = new FakeEventSource(url, options.withCredentials) 145 | return eventSource 146 | }, 147 | httpClientFactory(baseUrl, uid) { 148 | httpClient = new FakeHttpClient({ baseUrl, uid }) 149 | return httpClient 150 | }, 151 | }) 152 | 153 | const subscription = transmit.subscription('channel1') 154 | transmit.subscription('channel2') 155 | 156 | await subscription.create() 157 | 158 | // Simulate latency 159 | await setTimeout(100) 160 | 161 | assert.equal(httpClient!.sentRequests.length, 1) 162 | 163 | eventSource!.sendCloseEvent() 164 | eventSource!.sendOpenEvent() 165 | 166 | assert.equal(httpClient!.sentRequests.length, 2) 167 | }) 168 | }) 169 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@adonisjs/tsconfig/tsconfig.package.json", 3 | "compilerOptions": { 4 | "target": "ES2015", 5 | "lib": ["ES2015", "DOM"], 6 | }, 7 | "include": ["./**/*"] 8 | } 9 | --------------------------------------------------------------------------------