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