@mdsiha369/adonis-mercure 2.1.2 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +1 -1
- package/README.md +67 -7
- package/build/index.d.ts +3 -0
- package/build/index.js +1 -0
- package/build/src/define_config.js +23 -1
- package/build/src/errors/connection_error.d.ts +4 -0
- package/build/src/errors/connection_error.js +14 -0
- package/build/src/errors/mercure_error.d.ts +1 -1
- package/build/src/errors/mercure_error.js +2 -2
- package/build/src/fake_mercure.d.ts +5 -3
- package/build/src/fake_mercure.js +18 -4
- package/build/src/mercure.d.ts +3 -3
- package/build/src/mercure.js +7 -6
- package/build/src/publisher.js +5 -1
- package/build/src/token_generator.d.ts +3 -3
- package/build/src/token_generator.js +13 -6
- package/build/src/types/main.d.ts +8 -4
- package/package.json +4 -8
- package/build/bin/test.d.ts +0 -1
- package/build/bin/test.js +0 -8
- package/build/tests/fake_mercure.spec.d.ts +0 -1
- package/build/tests/fake_mercure.spec.js +0 -76
- package/build/tests/mercure.spec.d.ts +0 -1
- package/build/tests/mercure.spec.js +0 -84
- package/build/tests/publisher.spec.d.ts +0 -1
- package/build/tests/publisher.spec.js +0 -101
- package/build/tests/token_generator.spec.d.ts +0 -1
- package/build/tests/token_generator.spec.js +0 -29
- package/configure.ts +0 -41
- package/index.ts +0 -14
- package/providers/mercure_provider.ts +0 -23
- package/src/define_config.ts +0 -18
- package/src/errors/config_error.ts +0 -8
- package/src/errors/mercure_error.ts +0 -6
- package/src/errors/publish_error.ts +0 -14
- package/src/errors/timeout_error.ts +0 -8
- package/src/fake_mercure.ts +0 -74
- package/src/mercure.ts +0 -54
- package/src/publisher.ts +0 -62
- package/src/token_generator.ts +0 -29
- package/src/types/extended.ts +0 -7
- package/src/types/main.ts +0 -31
package/LICENSE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# The MIT License
|
|
2
2
|
|
|
3
|
-
Copyright (c) 2024
|
|
3
|
+
Copyright (c) 2024-2026 Michael DAS
|
|
4
4
|
|
|
5
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
6
|
|
package/README.md
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
## Table of Contents
|
|
21
21
|
|
|
22
|
+
- [Table of Contents](#table-of-contents)
|
|
22
23
|
- [Requirements](#requirements)
|
|
23
24
|
- [Installation](#installation)
|
|
24
25
|
- [Configuration](#configuration)
|
|
@@ -26,11 +27,15 @@
|
|
|
26
27
|
- [Publish an update](#publish-an-update)
|
|
27
28
|
- [Multiple topics](#multiple-topics)
|
|
28
29
|
- [Private updates](#private-updates)
|
|
29
|
-
- [SSE options](#sse-options-id-type-retry)
|
|
30
|
+
- [SSE options (id, type, retry)](#sse-options-id-type-retry)
|
|
30
31
|
- [Generate a subscriber token](#generate-a-subscriber-token)
|
|
31
32
|
- [Health check](#health-check)
|
|
32
33
|
- [Testing](#testing)
|
|
33
34
|
- [API Reference](#api-reference)
|
|
35
|
+
- [`send(topics, data?, options?)`](#sendtopics-data-options)
|
|
36
|
+
- [`generateSubscribeToken(topics)`](#generatesubscribetokentopics)
|
|
37
|
+
- [`generate(payload)`](#generatepayload)
|
|
38
|
+
- [`ping()`](#ping)
|
|
34
39
|
- [Roadmap](#roadmap)
|
|
35
40
|
- [License](#license)
|
|
36
41
|
|
|
@@ -38,7 +43,7 @@
|
|
|
38
43
|
|
|
39
44
|
## Requirements
|
|
40
45
|
|
|
41
|
-
- AdonisJS **v6**
|
|
46
|
+
- AdonisJS **v6** or **v7**
|
|
42
47
|
- Node.js **>= 20.6.0**
|
|
43
48
|
- A running [Mercure Hub](https://mercure.rocks/docs/hub/install)
|
|
44
49
|
|
|
@@ -143,14 +148,58 @@ const token = await mercure.generateSubscribeToken(['/orders/42'])
|
|
|
143
148
|
const token = await mercure.generate({ subscribe: ['/orders/42'] })
|
|
144
149
|
```
|
|
145
150
|
|
|
146
|
-
Pass
|
|
151
|
+
Tokens do not expire by default. Pass `expiresIn` (in seconds) to limit how long a token stays valid:
|
|
147
152
|
|
|
148
153
|
```ts
|
|
149
|
-
const
|
|
154
|
+
const token = await mercure.generateSubscribeToken(['/orders/42'], { expiresIn: 3600 })
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Browsers cannot send an `Authorization` header with `EventSource`, so there are two ways to give the token to the hub.
|
|
158
|
+
|
|
159
|
+
** Option 1: cookie (works with a plain `EventSource`)**
|
|
160
|
+
|
|
161
|
+
Set the token in a cookie from your AdonisJS app. Use `plainCookie`:
|
|
162
|
+
`response.cookie` signs the value and the hub would reject it.
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
router.get('/orders/:id/subscribe', async ({ params, response }) => {
|
|
166
|
+
const token = await mercure.generateSubscribeToken([`/orders/${params.id}`])
|
|
167
|
+
|
|
168
|
+
response.plainCookie('__Secure-mercure_access_token', token, {
|
|
169
|
+
httpOnly: true,
|
|
170
|
+
secure: true,
|
|
171
|
+
sameSite: 'lax',
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
return { ok: true }
|
|
175
|
+
})
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Then, in the browser:
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
const url = new URL('https://hub.example.com/.well-known/mercure')
|
|
150
182
|
url.searchParams.append('topic', '/orders/42')
|
|
151
183
|
|
|
152
|
-
const eventSource = new EventSource(url
|
|
184
|
+
const eventSource = new EventSource(url, { withCredentials: true })
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
- The cookie name is the `cookie_name` setting of your hub. Recent hubs default to `__Secure-mercure_access_token`.
|
|
188
|
+
- The app and the hub must share the same registrable domain (for example `app.example.com` and `hub.example.com`). Add `domain: '.example.com'` to the cookie options in that case.
|
|
189
|
+
- The `__Secure-` prefix requires HTTPS. For local HTTP development, configure a cookie name without the prefix in the hub and use `secure: false`.
|
|
190
|
+
|
|
191
|
+
**Option 2: `Authorization` header**
|
|
192
|
+
|
|
193
|
+
Use a client that reads the stream with `fetch()`, such as `@microsoft/fetch-event-source`:
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
import { fetchEventSource } from '@microsoft/fetch-event-source'
|
|
197
|
+
|
|
198
|
+
await fetchEventSource(url.toString(), {
|
|
153
199
|
headers: { Authorization: `Bearer ${token}` },
|
|
200
|
+
onmessage(event) {
|
|
201
|
+
console.log(event.data)
|
|
202
|
+
},
|
|
154
203
|
})
|
|
155
204
|
```
|
|
156
205
|
|
|
@@ -195,9 +244,15 @@ fake.assertNotSent('/admin/secret')
|
|
|
195
244
|
// assert nothing was sent at all
|
|
196
245
|
fake.assertNothingSent()
|
|
197
246
|
|
|
247
|
+
// assert an exact number of messages
|
|
248
|
+
fake.assertSentCount(2)
|
|
249
|
+
|
|
198
250
|
// inspect all recorded messages
|
|
199
251
|
const messages = fake.getSent()
|
|
200
252
|
|
|
253
|
+
// inspect the payloads passed to generate() / generateSubscribeToken()
|
|
254
|
+
const tokens = fake.getTokens()
|
|
255
|
+
|
|
201
256
|
// reset between tests
|
|
202
257
|
fake.clear()
|
|
203
258
|
```
|
|
@@ -226,7 +281,8 @@ Publishes an update to the Mercure Hub.
|
|
|
226
281
|
| `retry` | `number` | Client reconnect delay in milliseconds |
|
|
227
282
|
|
|
228
283
|
Throws `MercurePublishError` if the hub returns a non-2xx response.
|
|
229
|
-
Throws `MercureTimeoutError` if the hub does not respond within the configured timeout.
|
|
284
|
+
Throws `MercureTimeoutError` if the hub does not respond within the configured timeout.
|
|
285
|
+
Throws `MercureConnectionError` if the hub cannot be reached (connection refused, DNS failure, ...). The original error is available in `error.cause`.
|
|
230
286
|
|
|
231
287
|
---
|
|
232
288
|
|
|
@@ -238,6 +294,8 @@ Generates a JWT with `{ subscribe: topics }` for a frontend client.
|
|
|
238
294
|
const token = await mercure.generateSubscribeToken(['/chat/1', '/notifications/me'])
|
|
239
295
|
```
|
|
240
296
|
|
|
297
|
+
Takes an optional last argument `{ expiresIn }` (seconds) that sets the `exp` claim. Without it the token never expires.
|
|
298
|
+
|
|
241
299
|
---
|
|
242
300
|
|
|
243
301
|
### `generate(payload)`
|
|
@@ -248,11 +306,13 @@ Low-level JWT generation. Wraps `payload` under the `mercure` claim.
|
|
|
248
306
|
const token = await mercure.generate({ subscribe: ['/chat/1'], publish: ['/chat/1'] })
|
|
249
307
|
```
|
|
250
308
|
|
|
309
|
+
Takes an optional last argument `{ expiresIn }` (seconds) that sets the `exp` claim. Without it the token never expires.
|
|
310
|
+
|
|
251
311
|
---
|
|
252
312
|
|
|
253
313
|
### `ping()`
|
|
254
314
|
|
|
255
|
-
Returns `true` if the hub
|
|
315
|
+
Returns `true` if the hub answers, including with a 4xx status. Returns `false` if the hub answers with a 5xx status, if the request fails, or if it times out.
|
|
256
316
|
|
|
257
317
|
---
|
|
258
318
|
|
package/build/index.d.ts
CHANGED
|
@@ -7,3 +7,6 @@ export { MercureError } from './src/errors/mercure_error.js';
|
|
|
7
7
|
export { MercurePublishError } from './src/errors/publish_error.js';
|
|
8
8
|
export { MercureConfigError } from './src/errors/config_error.js';
|
|
9
9
|
export { MercureTimeoutError } from './src/errors/timeout_error.js';
|
|
10
|
+
export { MercureConnectionError } from './src/errors/connection_error.js';
|
|
11
|
+
export type { JwtAlgorithm, MercureConfig, MercureContract, SendOptions, TokenOptions, } from './src/types/main.js';
|
|
12
|
+
export type { SentMessage } from './src/fake_mercure.js';
|
package/build/index.js
CHANGED
|
@@ -12,3 +12,4 @@ export { MercureError } from './src/errors/mercure_error.js';
|
|
|
12
12
|
export { MercurePublishError } from './src/errors/publish_error.js';
|
|
13
13
|
export { MercureConfigError } from './src/errors/config_error.js';
|
|
14
14
|
export { MercureTimeoutError } from './src/errors/timeout_error.js';
|
|
15
|
+
export { MercureConnectionError } from './src/errors/connection_error.js';
|
|
@@ -1,9 +1,31 @@
|
|
|
1
1
|
import { MercureConfigError } from './errors/config_error.js';
|
|
2
|
-
const ALLOWED_ALGORITHMS = [
|
|
2
|
+
const ALLOWED_ALGORITHMS = [
|
|
3
|
+
'HS256',
|
|
4
|
+
'HS384',
|
|
5
|
+
'HS512',
|
|
6
|
+
'RS256',
|
|
7
|
+
'RS384',
|
|
8
|
+
'RS512',
|
|
9
|
+
];
|
|
10
|
+
function isHttpUrl(value) {
|
|
11
|
+
try {
|
|
12
|
+
const { protocol } = new URL(value);
|
|
13
|
+
return protocol === 'http:' || protocol === 'https:';
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
3
19
|
export function defineConfig(config) {
|
|
4
20
|
if (!ALLOWED_ALGORITHMS.includes(config.jwt.alg)) {
|
|
5
21
|
throw new MercureConfigError(`Algorithm "${config.jwt.alg}" is not allowed. Use one of: ${ALLOWED_ALGORITHMS.join(', ')}`);
|
|
6
22
|
}
|
|
23
|
+
if (!isHttpUrl(config.endpoint)) {
|
|
24
|
+
throw new MercureConfigError('endpoint must be a valid http(s) URL');
|
|
25
|
+
}
|
|
26
|
+
if (!config.adminToken) {
|
|
27
|
+
throw new MercureConfigError('adminToken must not be empty');
|
|
28
|
+
}
|
|
7
29
|
if (config.jwt.secret.length < 32) {
|
|
8
30
|
throw new MercureConfigError('jwt.secret must be at least 32 characters long');
|
|
9
31
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { MercureError } from './mercure_error.js';
|
|
2
|
+
export class MercureConnectionError extends MercureError {
|
|
3
|
+
constructor(cause) {
|
|
4
|
+
super(`Could not reach the Mercure hub: ${describe(cause)}`, { cause });
|
|
5
|
+
this.name = 'MercureConnectionError';
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
// fetch() throws a generic "fetch failed" and hides the real reason in `cause.code`
|
|
9
|
+
function describe(error) {
|
|
10
|
+
const code = error?.cause?.code;
|
|
11
|
+
if (code)
|
|
12
|
+
return code;
|
|
13
|
+
return error instanceof Error ? error.message : String(error);
|
|
14
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { MercureContract, SendOptions } from './types/main.js';
|
|
1
|
+
import type { MercureContract, SendOptions, TokenOptions } from './types/main.js';
|
|
2
2
|
export interface SentMessage {
|
|
3
3
|
topics: string[];
|
|
4
4
|
data: Record<string, unknown>;
|
|
@@ -6,13 +6,15 @@ export interface SentMessage {
|
|
|
6
6
|
}
|
|
7
7
|
export declare class FakeMercure implements MercureContract {
|
|
8
8
|
#private;
|
|
9
|
-
generate(payload: Record<string, unknown
|
|
10
|
-
generateSubscribeToken(topics: string[]): Promise<string>;
|
|
9
|
+
generate(payload: Record<string, unknown>, _options?: TokenOptions): Promise<string>;
|
|
10
|
+
generateSubscribeToken(topics: string[], options?: TokenOptions): Promise<string>;
|
|
11
11
|
send(topics: string | string[], data?: Record<string, unknown>, isPrivateOrOptions?: boolean | SendOptions): Promise<Response>;
|
|
12
12
|
ping(): Promise<boolean>;
|
|
13
13
|
assertSent(topic: string, data?: Record<string, unknown>): void;
|
|
14
14
|
assertNotSent(topic: string): void;
|
|
15
|
+
assertSentCount(expected: number): void;
|
|
15
16
|
assertNothingSent(): void;
|
|
16
17
|
getSent(): SentMessage[];
|
|
18
|
+
getTokens(): Array<Record<string, unknown>>;
|
|
17
19
|
clear(): void;
|
|
18
20
|
}
|
|
@@ -1,12 +1,18 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
+
// Compare what the hub would receive
|
|
3
|
+
function isSameData(a, b) {
|
|
4
|
+
return isDeepStrictEqual(JSON.parse(JSON.stringify(a)), JSON.parse(JSON.stringify(b)));
|
|
5
|
+
}
|
|
1
6
|
export class FakeMercure {
|
|
2
7
|
#sent = [];
|
|
3
8
|
#tokens = [];
|
|
4
|
-
|
|
9
|
+
// options (expiresIn) are accepted to match the contract but have no effect here
|
|
10
|
+
async generate(payload, _options) {
|
|
5
11
|
this.#tokens.push(payload);
|
|
6
12
|
return 'fake-token';
|
|
7
13
|
}
|
|
8
|
-
async generateSubscribeToken(topics) {
|
|
9
|
-
return this.generate({ subscribe: topics });
|
|
14
|
+
async generateSubscribeToken(topics, options) {
|
|
15
|
+
return this.generate({ subscribe: topics }, options);
|
|
10
16
|
}
|
|
11
17
|
async send(topics, data = {}, isPrivateOrOptions = false) {
|
|
12
18
|
const topicList = Array.isArray(topics) ? topics : [topics];
|
|
@@ -23,7 +29,7 @@ export class FakeMercure {
|
|
|
23
29
|
return false;
|
|
24
30
|
if (!data)
|
|
25
31
|
return true;
|
|
26
|
-
return
|
|
32
|
+
return isSameData(msg.data, data);
|
|
27
33
|
});
|
|
28
34
|
if (!match) {
|
|
29
35
|
const found = this.#sent.map((m) => m.topics.join(', ')).join('; ') || 'nothing';
|
|
@@ -36,6 +42,11 @@ export class FakeMercure {
|
|
|
36
42
|
throw new Error(`Expected no message on topic "${topic}" but one was found`);
|
|
37
43
|
}
|
|
38
44
|
}
|
|
45
|
+
assertSentCount(expected) {
|
|
46
|
+
if (this.#sent.length !== expected) {
|
|
47
|
+
throw new Error(`Expected ${expected} message(s) to be sent but found ${this.#sent.length}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
39
50
|
assertNothingSent() {
|
|
40
51
|
if (this.#sent.length > 0) {
|
|
41
52
|
const topics = this.#sent.map((m) => m.topics.join(', ')).join('; ');
|
|
@@ -45,6 +56,9 @@ export class FakeMercure {
|
|
|
45
56
|
getSent() {
|
|
46
57
|
return [...this.#sent];
|
|
47
58
|
}
|
|
59
|
+
getTokens() {
|
|
60
|
+
return [...this.#tokens];
|
|
61
|
+
}
|
|
48
62
|
clear() {
|
|
49
63
|
this.#sent = [];
|
|
50
64
|
this.#tokens = [];
|
package/build/src/mercure.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type { MercureConfig, MercureContract, SendOptions } from './types/main.js';
|
|
1
|
+
import type { MercureConfig, MercureContract, SendOptions, TokenOptions } from './types/main.js';
|
|
2
2
|
export declare class Mercure implements MercureContract {
|
|
3
3
|
#private;
|
|
4
4
|
constructor(config: MercureConfig);
|
|
5
|
-
generate(payload: Record<string, unknown
|
|
6
|
-
generateSubscribeToken(topics: string[]): Promise<string>;
|
|
5
|
+
generate(payload: Record<string, unknown>, options?: TokenOptions): Promise<string>;
|
|
6
|
+
generateSubscribeToken(topics: string[], options?: TokenOptions): Promise<string>;
|
|
7
7
|
send(topics: string | string[], data?: Record<string, unknown>, isPrivateOrOptions?: boolean | SendOptions): Promise<Response>;
|
|
8
8
|
ping(): Promise<boolean>;
|
|
9
9
|
}
|
package/build/src/mercure.js
CHANGED
|
@@ -10,11 +10,11 @@ export class Mercure {
|
|
|
10
10
|
this.#tokenGenerator = new TokenGenerator(config);
|
|
11
11
|
this.#publisher = new Publisher(config);
|
|
12
12
|
}
|
|
13
|
-
generate(payload) {
|
|
14
|
-
return this.#tokenGenerator.generate(payload);
|
|
13
|
+
generate(payload, options) {
|
|
14
|
+
return this.#tokenGenerator.generate(payload, options);
|
|
15
15
|
}
|
|
16
|
-
generateSubscribeToken(topics) {
|
|
17
|
-
return this.#tokenGenerator.generateSubscribeToken(topics);
|
|
16
|
+
generateSubscribeToken(topics, options) {
|
|
17
|
+
return this.#tokenGenerator.generateSubscribeToken(topics, options);
|
|
18
18
|
}
|
|
19
19
|
send(topics, data = {}, isPrivateOrOptions = false) {
|
|
20
20
|
const options = typeof isPrivateOrOptions === 'boolean' ? { private: isPrivateOrOptions } : isPrivateOrOptions;
|
|
@@ -25,11 +25,12 @@ export class Mercure {
|
|
|
25
25
|
const controller = timeout > 0 ? new AbortController() : null;
|
|
26
26
|
const timer = controller ? setTimeout(() => controller.abort(), timeout) : null;
|
|
27
27
|
try {
|
|
28
|
-
await fetch(this.#config.endpoint, {
|
|
28
|
+
const response = await fetch(this.#config.endpoint, {
|
|
29
29
|
method: 'HEAD',
|
|
30
30
|
signal: controller?.signal,
|
|
31
31
|
});
|
|
32
|
-
|
|
32
|
+
// 4xx still means the hub answered
|
|
33
|
+
return response.status < 500;
|
|
33
34
|
}
|
|
34
35
|
catch {
|
|
35
36
|
return false;
|
package/build/src/publisher.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { MercureConnectionError } from './errors/connection_error.js';
|
|
2
|
+
import { MercureError } from './errors/mercure_error.js';
|
|
1
3
|
import { MercurePublishError } from './errors/publish_error.js';
|
|
2
4
|
import { MercureTimeoutError } from './errors/timeout_error.js';
|
|
3
5
|
const DEFAULT_TIMEOUT = 5000;
|
|
@@ -41,10 +43,12 @@ export class Publisher {
|
|
|
41
43
|
return response;
|
|
42
44
|
}
|
|
43
45
|
catch (err) {
|
|
46
|
+
if (err instanceof MercureError)
|
|
47
|
+
throw err;
|
|
44
48
|
if (err?.name === 'AbortError') {
|
|
45
49
|
throw new MercureTimeoutError(timeout);
|
|
46
50
|
}
|
|
47
|
-
throw err;
|
|
51
|
+
throw new MercureConnectionError(err);
|
|
48
52
|
}
|
|
49
53
|
finally {
|
|
50
54
|
if (timer)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type { MercureConfig } from './types/main.js';
|
|
1
|
+
import type { MercureConfig, TokenOptions } from './types/main.js';
|
|
2
2
|
export declare class TokenGenerator {
|
|
3
3
|
#private;
|
|
4
4
|
constructor(config: MercureConfig);
|
|
5
|
-
generate(payload: Record<string, unknown
|
|
6
|
-
generateSubscribeToken(topics: string[]): Promise<string>;
|
|
5
|
+
generate(payload: Record<string, unknown>, options?: TokenOptions): Promise<string>;
|
|
6
|
+
generateSubscribeToken(topics: string[], options?: TokenOptions): Promise<string>;
|
|
7
7
|
}
|
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
import jws from 'jws';
|
|
2
|
+
import { MercureError } from './errors/mercure_error.js';
|
|
2
3
|
export class TokenGenerator {
|
|
3
4
|
#config;
|
|
4
5
|
constructor(config) {
|
|
5
6
|
this.#config = config;
|
|
6
7
|
}
|
|
7
|
-
generate(payload) {
|
|
8
|
+
async generate(payload, options = {}) {
|
|
9
|
+
const claims = { mercure: payload };
|
|
10
|
+
if (options.expiresIn !== undefined) {
|
|
11
|
+
if (!Number.isInteger(options.expiresIn) || options.expiresIn <= 0) {
|
|
12
|
+
throw new MercureError('expiresIn must be a positive integer (seconds)');
|
|
13
|
+
}
|
|
14
|
+
claims.exp = Math.floor(Date.now() / 1000) + options.expiresIn;
|
|
15
|
+
}
|
|
8
16
|
return new Promise((resolve, reject) => {
|
|
9
17
|
jws
|
|
10
18
|
.createSign({
|
|
11
|
-
payload:
|
|
19
|
+
payload: claims,
|
|
12
20
|
secret: this.#config.jwt.secret,
|
|
13
21
|
header: { alg: this.#config.jwt.alg },
|
|
14
22
|
})
|
|
@@ -16,9 +24,8 @@ export class TokenGenerator {
|
|
|
16
24
|
.on('done', resolve);
|
|
17
25
|
});
|
|
18
26
|
}
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return this.generate({ subscribe: topics });
|
|
27
|
+
// wildcard topic grants access to all private events, don't pass user input directly
|
|
28
|
+
generateSubscribeToken(topics, options) {
|
|
29
|
+
return this.generate({ subscribe: topics }, options);
|
|
23
30
|
}
|
|
24
31
|
}
|
|
@@ -1,15 +1,19 @@
|
|
|
1
|
-
|
|
1
|
+
export type JwtAlgorithm = 'HS256' | 'HS384' | 'HS512' | 'RS256' | 'RS384' | 'RS512';
|
|
2
2
|
export interface MercureConfig {
|
|
3
3
|
endpoint: string;
|
|
4
4
|
adminToken: string;
|
|
5
5
|
jwt: {
|
|
6
|
-
alg:
|
|
6
|
+
alg: JwtAlgorithm;
|
|
7
7
|
secret: string;
|
|
8
8
|
};
|
|
9
9
|
http?: {
|
|
10
10
|
timeout?: number;
|
|
11
11
|
};
|
|
12
12
|
}
|
|
13
|
+
export interface TokenOptions {
|
|
14
|
+
/** Token lifetime in seconds. No `exp` claim is set when omitted */
|
|
15
|
+
expiresIn?: number;
|
|
16
|
+
}
|
|
13
17
|
export interface SendOptions {
|
|
14
18
|
private?: boolean;
|
|
15
19
|
id?: string;
|
|
@@ -17,8 +21,8 @@ export interface SendOptions {
|
|
|
17
21
|
retry?: number;
|
|
18
22
|
}
|
|
19
23
|
export interface MercureContract {
|
|
20
|
-
generate(payload: Record<string, unknown
|
|
21
|
-
generateSubscribeToken(topics: string[]): Promise<string>;
|
|
24
|
+
generate(payload: Record<string, unknown>, options?: TokenOptions): Promise<string>;
|
|
25
|
+
generateSubscribeToken(topics: string[], options?: TokenOptions): Promise<string>;
|
|
22
26
|
send(topics: string | string[], data?: Record<string, unknown>, options?: boolean | SendOptions): Promise<Response>;
|
|
23
27
|
ping(): Promise<boolean>;
|
|
24
28
|
}
|
package/package.json
CHANGED
|
@@ -1,17 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mdsiha369/adonis-mercure",
|
|
3
3
|
"description": "Mercure Hub integration for AdonisJS v6 — publish real-time updates via Server-Sent Events (SSE)",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.2.0",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=20.6.0"
|
|
7
7
|
},
|
|
8
8
|
"main": "./build/index.js",
|
|
9
9
|
"type": "module",
|
|
10
10
|
"files": [
|
|
11
|
-
"src",
|
|
12
|
-
"providers",
|
|
13
|
-
"index.ts",
|
|
14
|
-
"configure.ts",
|
|
15
11
|
"build"
|
|
16
12
|
],
|
|
17
13
|
"exports": {
|
|
@@ -30,7 +26,7 @@
|
|
|
30
26
|
"pretest": "npm run lint",
|
|
31
27
|
"test": "c8 npm run quick:test",
|
|
32
28
|
"prebuild": "npm run lint && npm run clean",
|
|
33
|
-
"build": "tsc",
|
|
29
|
+
"build": "tsc -p tsconfig.build.json",
|
|
34
30
|
"postbuild": "npm run copy:templates",
|
|
35
31
|
"release": "np",
|
|
36
32
|
"version": "npm run build",
|
|
@@ -60,14 +56,14 @@
|
|
|
60
56
|
"jws": "^4.0.0"
|
|
61
57
|
},
|
|
62
58
|
"peerDependencies": {
|
|
63
|
-
"@adonisjs/core": "^6.2.0"
|
|
59
|
+
"@adonisjs/core": "^6.2.0 || ^7.0.0"
|
|
64
60
|
},
|
|
65
61
|
"author": "Michael DAŞ",
|
|
66
62
|
"license": "MIT",
|
|
67
63
|
"homepage": "https://github.com/mdsiha/adonis-mercure#readme",
|
|
68
64
|
"repository": {
|
|
69
65
|
"type": "git",
|
|
70
|
-
"url": "git+
|
|
66
|
+
"url": "git+https://github.com/mdsiha/adonis-mercure.git"
|
|
71
67
|
},
|
|
72
68
|
"bugs": {
|
|
73
69
|
"url": "https://github.com/mdsiha/adonis-mercure/issues"
|
package/build/bin/test.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
package/build/bin/test.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
import { test } from '@japa/runner';
|
|
2
|
-
import { FakeMercure } from '../src/fake_mercure.js';
|
|
3
|
-
test.group('FakeMercure', () => {
|
|
4
|
-
test('records sent messages', async ({ assert }) => {
|
|
5
|
-
const fake = new FakeMercure();
|
|
6
|
-
await fake.send('/orders/1', { status: 'shipped' });
|
|
7
|
-
const sent = fake.getSent();
|
|
8
|
-
assert.lengthOf(sent, 1);
|
|
9
|
-
assert.deepEqual(sent[0].topics, ['/orders/1']);
|
|
10
|
-
assert.deepEqual(sent[0].data, { status: 'shipped' });
|
|
11
|
-
});
|
|
12
|
-
test('assertSent passes when topic matches', async ({ assert }) => {
|
|
13
|
-
const fake = new FakeMercure();
|
|
14
|
-
await fake.send('/topic', { foo: 'bar' });
|
|
15
|
-
assert.doesNotThrow(() => fake.assertSent('/topic'));
|
|
16
|
-
});
|
|
17
|
-
test('assertSent passes when topic and data match', async ({ assert }) => {
|
|
18
|
-
const fake = new FakeMercure();
|
|
19
|
-
await fake.send('/topic', { foo: 'bar' });
|
|
20
|
-
assert.doesNotThrow(() => fake.assertSent('/topic', { foo: 'bar' }));
|
|
21
|
-
});
|
|
22
|
-
test('assertSent throws when topic was not sent', ({ assert }) => {
|
|
23
|
-
const fake = new FakeMercure();
|
|
24
|
-
assert.throws(() => fake.assertSent('/missing'));
|
|
25
|
-
});
|
|
26
|
-
test('assertSent throws when data does not match', async ({ assert }) => {
|
|
27
|
-
const fake = new FakeMercure();
|
|
28
|
-
await fake.send('/topic', { foo: 'bar' });
|
|
29
|
-
assert.throws(() => fake.assertSent('/topic', { foo: 'wrong' }));
|
|
30
|
-
});
|
|
31
|
-
test('assertNotSent passes when topic was never sent', ({ assert }) => {
|
|
32
|
-
const fake = new FakeMercure();
|
|
33
|
-
assert.doesNotThrow(() => fake.assertNotSent('/topic'));
|
|
34
|
-
});
|
|
35
|
-
test('assertNotSent throws when topic was sent', async ({ assert }) => {
|
|
36
|
-
const fake = new FakeMercure();
|
|
37
|
-
await fake.send('/topic', {});
|
|
38
|
-
assert.throws(() => fake.assertNotSent('/topic'));
|
|
39
|
-
});
|
|
40
|
-
test('assertNothingSent passes on fresh instance', ({ assert }) => {
|
|
41
|
-
const fake = new FakeMercure();
|
|
42
|
-
assert.doesNotThrow(() => fake.assertNothingSent());
|
|
43
|
-
});
|
|
44
|
-
test('assertNothingSent throws after a send', async ({ assert }) => {
|
|
45
|
-
const fake = new FakeMercure();
|
|
46
|
-
await fake.send('/topic', {});
|
|
47
|
-
assert.throws(() => fake.assertNothingSent());
|
|
48
|
-
});
|
|
49
|
-
test('clear resets all recorded messages', async ({ assert }) => {
|
|
50
|
-
const fake = new FakeMercure();
|
|
51
|
-
await fake.send('/topic', {});
|
|
52
|
-
fake.clear();
|
|
53
|
-
assert.lengthOf(fake.getSent(), 0);
|
|
54
|
-
assert.doesNotThrow(() => fake.assertNothingSent());
|
|
55
|
-
});
|
|
56
|
-
test('handles boolean true as private option', async ({ assert }) => {
|
|
57
|
-
const fake = new FakeMercure();
|
|
58
|
-
await fake.send('/topic', {}, true);
|
|
59
|
-
const sent = fake.getSent();
|
|
60
|
-
assert.isTrue(sent[0].options.private);
|
|
61
|
-
});
|
|
62
|
-
test('ping always returns true', async ({ assert }) => {
|
|
63
|
-
const fake = new FakeMercure();
|
|
64
|
-
assert.isTrue(await fake.ping());
|
|
65
|
-
});
|
|
66
|
-
test('generate stores the payload and returns fake-token', async ({ assert }) => {
|
|
67
|
-
const fake = new FakeMercure();
|
|
68
|
-
const token = await fake.generate({ subscribe: ['/chat/1'] });
|
|
69
|
-
assert.equal(token, 'fake-token');
|
|
70
|
-
});
|
|
71
|
-
test('generateSubscribeToken returns fake-token', async ({ assert }) => {
|
|
72
|
-
const fake = new FakeMercure();
|
|
73
|
-
const token = await fake.generateSubscribeToken(['/orders/42']);
|
|
74
|
-
assert.equal(token, 'fake-token');
|
|
75
|
-
});
|
|
76
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
import { test } from '@japa/runner';
|
|
2
|
-
import { Mercure } from '../src/mercure.js';
|
|
3
|
-
const config = {
|
|
4
|
-
endpoint: 'http://localhost:3000/.well-known/mercure',
|
|
5
|
-
adminToken: 'test-admin-token',
|
|
6
|
-
jwt: { alg: 'HS256', secret: 'test-secret-long-enough-for-tests-ok' },
|
|
7
|
-
};
|
|
8
|
-
test.group('Mercure', (group) => {
|
|
9
|
-
let originalFetch;
|
|
10
|
-
group.each.setup(() => {
|
|
11
|
-
originalFetch = globalThis.fetch;
|
|
12
|
-
});
|
|
13
|
-
group.each.teardown(() => {
|
|
14
|
-
globalThis.fetch = originalFetch;
|
|
15
|
-
});
|
|
16
|
-
test('generate returns a signed JWT string', async ({ assert }) => {
|
|
17
|
-
const mercure = new Mercure(config);
|
|
18
|
-
const token = await mercure.generate({ subscribe: ['/chat/1'] });
|
|
19
|
-
assert.isString(token);
|
|
20
|
-
assert.equal(token.split('.').length, 3);
|
|
21
|
-
});
|
|
22
|
-
test('generateSubscribeToken returns JWT with subscribe claim', async ({ assert }) => {
|
|
23
|
-
const mercure = new Mercure(config);
|
|
24
|
-
const token = await mercure.generateSubscribeToken(['/orders/42']);
|
|
25
|
-
const raw = token.split('.')[1];
|
|
26
|
-
const payload = JSON.parse(Buffer.from(raw, 'base64url').toString());
|
|
27
|
-
assert.deepEqual(payload.mercure.subscribe, ['/orders/42']);
|
|
28
|
-
});
|
|
29
|
-
test('send publishes topic and data to the hub', async ({ assert }) => {
|
|
30
|
-
let capturedBody = '';
|
|
31
|
-
globalThis.fetch = async (_url, init) => {
|
|
32
|
-
capturedBody = init.body;
|
|
33
|
-
return new Response('1', { status: 200 });
|
|
34
|
-
};
|
|
35
|
-
await new Mercure(config).send('/topic', { msg: 'hello' });
|
|
36
|
-
const body = new URLSearchParams(capturedBody);
|
|
37
|
-
assert.equal(body.get('topic'), '/topic');
|
|
38
|
-
assert.deepEqual(JSON.parse(body.get('data')), { msg: 'hello' });
|
|
39
|
-
});
|
|
40
|
-
test('send converts boolean true to private=on', async ({ assert }) => {
|
|
41
|
-
let capturedBody = '';
|
|
42
|
-
globalThis.fetch = async (_url, init) => {
|
|
43
|
-
capturedBody = init.body;
|
|
44
|
-
return new Response('1', { status: 200 });
|
|
45
|
-
};
|
|
46
|
-
await new Mercure(config).send('/topic', {}, true);
|
|
47
|
-
const body = new URLSearchParams(capturedBody);
|
|
48
|
-
assert.equal(body.get('private'), 'on');
|
|
49
|
-
});
|
|
50
|
-
test('send accepts a SendOptions object', async ({ assert }) => {
|
|
51
|
-
let capturedBody = '';
|
|
52
|
-
globalThis.fetch = async (_url, init) => {
|
|
53
|
-
capturedBody = init.body;
|
|
54
|
-
return new Response('1', { status: 200 });
|
|
55
|
-
};
|
|
56
|
-
await new Mercure(config).send('/topic', {}, { private: true, id: 'evt-1', type: 'order.shipped' });
|
|
57
|
-
const body = new URLSearchParams(capturedBody);
|
|
58
|
-
assert.equal(body.get('private'), 'on');
|
|
59
|
-
assert.equal(body.get('id'), 'evt-1');
|
|
60
|
-
assert.equal(body.get('type'), 'order.shipped');
|
|
61
|
-
});
|
|
62
|
-
test('ping returns true when hub responds', async ({ assert }) => {
|
|
63
|
-
globalThis.fetch = async () => new Response('', { status: 200 });
|
|
64
|
-
assert.isTrue(await new Mercure(config).ping());
|
|
65
|
-
});
|
|
66
|
-
test('ping returns true even on hub 4xx response', async ({ assert }) => {
|
|
67
|
-
globalThis.fetch = async () => new Response('', { status: 401 });
|
|
68
|
-
assert.isTrue(await new Mercure(config).ping());
|
|
69
|
-
});
|
|
70
|
-
test('ping returns false on network error', async ({ assert }) => {
|
|
71
|
-
globalThis.fetch = async () => {
|
|
72
|
-
throw new Error('Network error');
|
|
73
|
-
};
|
|
74
|
-
assert.isFalse(await new Mercure(config).ping());
|
|
75
|
-
});
|
|
76
|
-
test('ping returns false on timeout', async ({ assert }) => {
|
|
77
|
-
globalThis.fetch = async () => {
|
|
78
|
-
const err = new Error('The operation was aborted');
|
|
79
|
-
err.name = 'AbortError';
|
|
80
|
-
throw err;
|
|
81
|
-
};
|
|
82
|
-
assert.isFalse(await new Mercure(config).ping());
|
|
83
|
-
});
|
|
84
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
import { test } from '@japa/runner';
|
|
2
|
-
import { Publisher } from '../src/publisher.js';
|
|
3
|
-
import { MercurePublishError } from '../src/errors/publish_error.js';
|
|
4
|
-
import { MercureTimeoutError } from '../src/errors/timeout_error.js';
|
|
5
|
-
const config = {
|
|
6
|
-
endpoint: 'http://localhost:3000/.well-known/mercure',
|
|
7
|
-
adminToken: 'test-token',
|
|
8
|
-
jwt: { alg: 'HS256', secret: 'test-secret' },
|
|
9
|
-
};
|
|
10
|
-
test.group('Publisher', (group) => {
|
|
11
|
-
let originalFetch;
|
|
12
|
-
group.each.setup(() => {
|
|
13
|
-
originalFetch = globalThis.fetch;
|
|
14
|
-
});
|
|
15
|
-
group.each.teardown(() => {
|
|
16
|
-
globalThis.fetch = originalFetch;
|
|
17
|
-
});
|
|
18
|
-
test('posts to the configured endpoint', async ({ assert }) => {
|
|
19
|
-
let capturedUrl = '';
|
|
20
|
-
globalThis.fetch = async (url, _init) => {
|
|
21
|
-
capturedUrl = url;
|
|
22
|
-
return new Response('1', { status: 200 });
|
|
23
|
-
};
|
|
24
|
-
const publisher = new Publisher(config);
|
|
25
|
-
await publisher.publish('test-topic', { hello: 'world' });
|
|
26
|
-
assert.equal(capturedUrl, config.endpoint);
|
|
27
|
-
});
|
|
28
|
-
test('sends topic and data in the body', async ({ assert }) => {
|
|
29
|
-
let capturedBody = '';
|
|
30
|
-
globalThis.fetch = async (_url, init) => {
|
|
31
|
-
capturedBody = init.body;
|
|
32
|
-
return new Response('1', { status: 200 });
|
|
33
|
-
};
|
|
34
|
-
const publisher = new Publisher(config);
|
|
35
|
-
await publisher.publish('my-topic', { msg: 'hello' });
|
|
36
|
-
const body = new URLSearchParams(capturedBody);
|
|
37
|
-
assert.equal(body.get('topic'), 'my-topic');
|
|
38
|
-
assert.deepEqual(JSON.parse(body.get('data')), { msg: 'hello' });
|
|
39
|
-
});
|
|
40
|
-
test('appends private=on when option is set', async ({ assert }) => {
|
|
41
|
-
let capturedBody = '';
|
|
42
|
-
globalThis.fetch = async (_url, init) => {
|
|
43
|
-
capturedBody = init.body;
|
|
44
|
-
return new Response('1', { status: 200 });
|
|
45
|
-
};
|
|
46
|
-
await new Publisher(config).publish('topic', {}, { private: true });
|
|
47
|
-
const body = new URLSearchParams(capturedBody);
|
|
48
|
-
assert.equal(body.get('private'), 'on');
|
|
49
|
-
});
|
|
50
|
-
test('appends id, type, retry when provided', async ({ assert }) => {
|
|
51
|
-
let capturedBody = '';
|
|
52
|
-
globalThis.fetch = async (_url, init) => {
|
|
53
|
-
capturedBody = init.body;
|
|
54
|
-
return new Response('1', { status: 200 });
|
|
55
|
-
};
|
|
56
|
-
await new Publisher(config).publish('topic', {}, { id: 'msg-1', type: 'order.created', retry: 5000 });
|
|
57
|
-
const body = new URLSearchParams(capturedBody);
|
|
58
|
-
assert.equal(body.get('id'), 'msg-1');
|
|
59
|
-
assert.equal(body.get('type'), 'order.created');
|
|
60
|
-
assert.equal(body.get('retry'), '5000');
|
|
61
|
-
});
|
|
62
|
-
test('throws MercurePublishError on non-2xx response', async ({ assert }) => {
|
|
63
|
-
globalThis.fetch = async () => new Response('Unauthorized', { status: 401 });
|
|
64
|
-
let caught;
|
|
65
|
-
try {
|
|
66
|
-
await new Publisher(config).publish('topic', {});
|
|
67
|
-
}
|
|
68
|
-
catch (err) {
|
|
69
|
-
caught = err;
|
|
70
|
-
}
|
|
71
|
-
assert.instanceOf(caught, MercurePublishError);
|
|
72
|
-
assert.equal(caught.statusCode, 401);
|
|
73
|
-
});
|
|
74
|
-
test('throws MercureTimeoutError when request times out', async ({ assert }) => {
|
|
75
|
-
globalThis.fetch = async () => {
|
|
76
|
-
const err = new Error('The operation was aborted');
|
|
77
|
-
err.name = 'AbortError';
|
|
78
|
-
throw err;
|
|
79
|
-
};
|
|
80
|
-
let caught;
|
|
81
|
-
try {
|
|
82
|
-
await new Publisher(config).publish('topic', {});
|
|
83
|
-
}
|
|
84
|
-
catch (err) {
|
|
85
|
-
caught = err;
|
|
86
|
-
}
|
|
87
|
-
assert.instanceOf(caught, MercureTimeoutError);
|
|
88
|
-
assert.equal(caught.timeoutMs, 5000);
|
|
89
|
-
});
|
|
90
|
-
test('handles multiple topics', async ({ assert }) => {
|
|
91
|
-
let capturedBody = '';
|
|
92
|
-
globalThis.fetch = async (_url, init) => {
|
|
93
|
-
capturedBody = init.body;
|
|
94
|
-
return new Response('1', { status: 200 });
|
|
95
|
-
};
|
|
96
|
-
await new Publisher(config).publish(['/a', '/b'], {});
|
|
97
|
-
// URLSearchParams.getAll handles repeated keys
|
|
98
|
-
const body = new URLSearchParams(capturedBody);
|
|
99
|
-
assert.deepEqual(body.getAll('topic'), ['/a', '/b']);
|
|
100
|
-
});
|
|
101
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { test } from '@japa/runner';
|
|
2
|
-
import { TokenGenerator } from '../src/token_generator.js';
|
|
3
|
-
const config = {
|
|
4
|
-
endpoint: 'http://localhost:3000/.well-known/mercure',
|
|
5
|
-
adminToken: 'test-token',
|
|
6
|
-
jwt: { alg: 'HS256', secret: 'test-secret' },
|
|
7
|
-
};
|
|
8
|
-
test.group('TokenGenerator', () => {
|
|
9
|
-
test('generate returns a JWT string', async ({ assert }) => {
|
|
10
|
-
const generator = new TokenGenerator(config);
|
|
11
|
-
const token = await generator.generate({ subscribe: ['*'] });
|
|
12
|
-
assert.isString(token);
|
|
13
|
-
assert.equal(token.split('.').length, 3);
|
|
14
|
-
});
|
|
15
|
-
test('generated token contains the mercure claim', async ({ assert }) => {
|
|
16
|
-
const generator = new TokenGenerator(config);
|
|
17
|
-
const token = await generator.generate({ subscribe: ['/chat/1'] });
|
|
18
|
-
const raw = token.split('.')[1];
|
|
19
|
-
const payload = JSON.parse(Buffer.from(raw, 'base64url').toString());
|
|
20
|
-
assert.deepEqual(payload.mercure.subscribe, ['/chat/1']);
|
|
21
|
-
});
|
|
22
|
-
test('generateSubscribeToken wraps topics in subscribe field', async ({ assert }) => {
|
|
23
|
-
const generator = new TokenGenerator(config);
|
|
24
|
-
const token = await generator.generateSubscribeToken(['/orders/42']);
|
|
25
|
-
const raw = token.split('.')[1];
|
|
26
|
-
const payload = JSON.parse(Buffer.from(raw, 'base64url').toString());
|
|
27
|
-
assert.deepEqual(payload.mercure.subscribe, ['/orders/42']);
|
|
28
|
-
});
|
|
29
|
-
});
|
package/configure.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
|--------------------------------------------------------------------------
|
|
3
|
-
| Configure hook
|
|
4
|
-
|--------------------------------------------------------------------------
|
|
5
|
-
|
|
|
6
|
-
| The configure hook is called when someone runs "node ace configure <package>"
|
|
7
|
-
| command. You are free to perform any operations inside this function to
|
|
8
|
-
| configure the package.
|
|
9
|
-
|
|
|
10
|
-
| To make things easier, you have access to the underlying "ConfigureCommand"
|
|
11
|
-
| instance and you can use codemods to modify the source files.
|
|
12
|
-
|
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import ConfigureCommand from '@adonisjs/core/commands/configure'
|
|
16
|
-
import { stubsRoot } from './stubs/main.js'
|
|
17
|
-
|
|
18
|
-
export async function configure(_command: ConfigureCommand) {
|
|
19
|
-
const codemods = await _command.createCodemods()
|
|
20
|
-
|
|
21
|
-
await codemods.makeUsingStub(stubsRoot, 'config/mercure.stub', {})
|
|
22
|
-
|
|
23
|
-
await codemods.defineEnvVariables({
|
|
24
|
-
MERCURE_ENDPOINT: 'http://localhost:3000/.well-known/mercure',
|
|
25
|
-
MERCURE_ADMIN_JWT: '',
|
|
26
|
-
MERCURE_JWT_SECRET: '',
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
await codemods.defineEnvValidations({
|
|
30
|
-
variables: {
|
|
31
|
-
MERCURE_ENDPOINT: `Env.schema.string({ format: 'url' })`,
|
|
32
|
-
MERCURE_ADMIN_JWT: `Env.schema.string()`,
|
|
33
|
-
MERCURE_JWT_SECRET: `Env.schema.string()`,
|
|
34
|
-
},
|
|
35
|
-
leadingComment: 'Mercure configuration',
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
await codemods.updateRcFile((rcFile) => {
|
|
39
|
-
rcFile.addProvider('@mdsiha369/adonis-mercure/mercure_provider')
|
|
40
|
-
})
|
|
41
|
-
}
|
package/index.ts
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
|--------------------------------------------------------------------------
|
|
3
|
-
| Package entrypoint
|
|
4
|
-
|--------------------------------------------------------------------------
|
|
5
|
-
*/
|
|
6
|
-
export { configure } from './configure.js'
|
|
7
|
-
export { Mercure } from './src/mercure.js'
|
|
8
|
-
export { FakeMercure } from './src/fake_mercure.js'
|
|
9
|
-
export { defineConfig } from './src/define_config.js'
|
|
10
|
-
export { stubsRoot } from './stubs/main.js'
|
|
11
|
-
export { MercureError } from './src/errors/mercure_error.js'
|
|
12
|
-
export { MercurePublishError } from './src/errors/publish_error.js'
|
|
13
|
-
export { MercureConfigError } from './src/errors/config_error.js'
|
|
14
|
-
export { MercureTimeoutError } from './src/errors/timeout_error.js'
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import '../src/types/extended.js'
|
|
2
|
-
import { Mercure } from '../src/mercure.js'
|
|
3
|
-
import { MercureConfigError } from '../src/errors/config_error.js'
|
|
4
|
-
import type { ApplicationService } from '@adonisjs/core/types'
|
|
5
|
-
import type { MercureConfig } from '../src/types/main.js'
|
|
6
|
-
|
|
7
|
-
export default class MercureProvider {
|
|
8
|
-
constructor(protected app: ApplicationService) {}
|
|
9
|
-
|
|
10
|
-
async boot() {
|
|
11
|
-
this.app.container.singleton('mercure', async () => {
|
|
12
|
-
const config = this.app.config.get<MercureConfig | undefined>('mercure')
|
|
13
|
-
|
|
14
|
-
if (!config?.endpoint || !config?.adminToken || !config?.jwt?.secret) {
|
|
15
|
-
throw new MercureConfigError(
|
|
16
|
-
'Missing or incomplete Mercure config. Run "node ace configure @mdsiha369/adonis-mercure"'
|
|
17
|
-
)
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
return new Mercure(config)
|
|
21
|
-
})
|
|
22
|
-
}
|
|
23
|
-
}
|
package/src/define_config.ts
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import { MercureConfigError } from './errors/config_error.js'
|
|
2
|
-
import type { MercureConfig } from './types/main.js'
|
|
3
|
-
|
|
4
|
-
const ALLOWED_ALGORITHMS = ['HS256', 'HS384', 'HS512', 'RS256', 'RS384', 'RS512'] as const
|
|
5
|
-
|
|
6
|
-
export function defineConfig<T extends MercureConfig>(config: T): T {
|
|
7
|
-
if (!ALLOWED_ALGORITHMS.includes(config.jwt.alg as (typeof ALLOWED_ALGORITHMS)[number])) {
|
|
8
|
-
throw new MercureConfigError(
|
|
9
|
-
`Algorithm "${config.jwt.alg}" is not allowed. Use one of: ${ALLOWED_ALGORITHMS.join(', ')}`
|
|
10
|
-
)
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
if (config.jwt.secret.length < 32) {
|
|
14
|
-
throw new MercureConfigError('jwt.secret must be at least 32 characters long')
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
return config
|
|
18
|
-
}
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import { MercureError } from './mercure_error.js'
|
|
2
|
-
|
|
3
|
-
export class MercurePublishError extends MercureError {
|
|
4
|
-
readonly responseBody: string
|
|
5
|
-
|
|
6
|
-
constructor(
|
|
7
|
-
readonly statusCode: number,
|
|
8
|
-
responseBody: string
|
|
9
|
-
) {
|
|
10
|
-
super(`Mercure hub returned ${statusCode}`)
|
|
11
|
-
this.name = 'MercurePublishError'
|
|
12
|
-
this.responseBody = responseBody.slice(0, 200)
|
|
13
|
-
}
|
|
14
|
-
}
|
package/src/fake_mercure.ts
DELETED
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
import type { MercureContract, SendOptions } from './types/main.js'
|
|
2
|
-
|
|
3
|
-
export interface SentMessage {
|
|
4
|
-
topics: string[]
|
|
5
|
-
data: Record<string, unknown>
|
|
6
|
-
options: SendOptions
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export class FakeMercure implements MercureContract {
|
|
10
|
-
#sent: SentMessage[] = []
|
|
11
|
-
#tokens: Array<Record<string, unknown>> = []
|
|
12
|
-
|
|
13
|
-
async generate(payload: Record<string, unknown>): Promise<string> {
|
|
14
|
-
this.#tokens.push(payload)
|
|
15
|
-
return 'fake-token'
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
async generateSubscribeToken(topics: string[]): Promise<string> {
|
|
19
|
-
return this.generate({ subscribe: topics })
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
async send(
|
|
23
|
-
topics: string | string[],
|
|
24
|
-
data: Record<string, unknown> = {},
|
|
25
|
-
isPrivateOrOptions: boolean | SendOptions = false
|
|
26
|
-
): Promise<Response> {
|
|
27
|
-
const topicList = Array.isArray(topics) ? topics : [topics]
|
|
28
|
-
const options: SendOptions =
|
|
29
|
-
typeof isPrivateOrOptions === 'boolean' ? { private: isPrivateOrOptions } : isPrivateOrOptions
|
|
30
|
-
|
|
31
|
-
this.#sent.push({ topics: topicList, data, options })
|
|
32
|
-
return new Response('1', { status: 200 })
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
async ping(): Promise<boolean> {
|
|
36
|
-
return true
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
assertSent(topic: string, data?: Record<string, unknown>): void {
|
|
40
|
-
const match = this.#sent.find((msg) => {
|
|
41
|
-
if (!msg.topics.includes(topic)) return false
|
|
42
|
-
if (!data) return true
|
|
43
|
-
return JSON.stringify(msg.data) === JSON.stringify(data)
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
if (!match) {
|
|
47
|
-
const found = this.#sent.map((m) => m.topics.join(', ')).join('; ') || 'nothing'
|
|
48
|
-
throw new Error(`Expected a message on topic "${topic}" but found: [${found}]`)
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
assertNotSent(topic: string): void {
|
|
53
|
-
const match = this.#sent.find((msg) => msg.topics.includes(topic))
|
|
54
|
-
if (match) {
|
|
55
|
-
throw new Error(`Expected no message on topic "${topic}" but one was found`)
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
assertNothingSent(): void {
|
|
60
|
-
if (this.#sent.length > 0) {
|
|
61
|
-
const topics = this.#sent.map((m) => m.topics.join(', ')).join('; ')
|
|
62
|
-
throw new Error(`Expected nothing to be sent but found messages on: [${topics}]`)
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
getSent(): SentMessage[] {
|
|
67
|
-
return [...this.#sent]
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
clear(): void {
|
|
71
|
-
this.#sent = []
|
|
72
|
-
this.#tokens = []
|
|
73
|
-
}
|
|
74
|
-
}
|
package/src/mercure.ts
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
import { TokenGenerator } from './token_generator.js'
|
|
2
|
-
import { Publisher } from './publisher.js'
|
|
3
|
-
import type { MercureConfig, MercureContract, SendOptions } from './types/main.js'
|
|
4
|
-
|
|
5
|
-
const DEFAULT_TIMEOUT = 5000
|
|
6
|
-
|
|
7
|
-
export class Mercure implements MercureContract {
|
|
8
|
-
#config: MercureConfig
|
|
9
|
-
#tokenGenerator: TokenGenerator
|
|
10
|
-
#publisher: Publisher
|
|
11
|
-
|
|
12
|
-
constructor(config: MercureConfig) {
|
|
13
|
-
this.#config = config
|
|
14
|
-
this.#tokenGenerator = new TokenGenerator(config)
|
|
15
|
-
this.#publisher = new Publisher(config)
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
generate(payload: Record<string, unknown>): Promise<string> {
|
|
19
|
-
return this.#tokenGenerator.generate(payload)
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
generateSubscribeToken(topics: string[]): Promise<string> {
|
|
23
|
-
return this.#tokenGenerator.generateSubscribeToken(topics)
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
send(
|
|
27
|
-
topics: string | string[],
|
|
28
|
-
data: Record<string, unknown> = {},
|
|
29
|
-
isPrivateOrOptions: boolean | SendOptions = false
|
|
30
|
-
): Promise<Response> {
|
|
31
|
-
const options: SendOptions =
|
|
32
|
-
typeof isPrivateOrOptions === 'boolean' ? { private: isPrivateOrOptions } : isPrivateOrOptions
|
|
33
|
-
|
|
34
|
-
return this.#publisher.publish(topics, data, options)
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
async ping(): Promise<boolean> {
|
|
38
|
-
const timeout = this.#config.http?.timeout ?? DEFAULT_TIMEOUT
|
|
39
|
-
const controller = timeout > 0 ? new AbortController() : null
|
|
40
|
-
const timer = controller ? setTimeout(() => controller.abort(), timeout) : null
|
|
41
|
-
|
|
42
|
-
try {
|
|
43
|
-
await fetch(this.#config.endpoint, {
|
|
44
|
-
method: 'HEAD',
|
|
45
|
-
signal: controller?.signal,
|
|
46
|
-
})
|
|
47
|
-
return true
|
|
48
|
-
} catch {
|
|
49
|
-
return false
|
|
50
|
-
} finally {
|
|
51
|
-
if (timer) clearTimeout(timer)
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
}
|
package/src/publisher.ts
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import { MercurePublishError } from './errors/publish_error.js'
|
|
2
|
-
import { MercureTimeoutError } from './errors/timeout_error.js'
|
|
3
|
-
import type { MercureConfig, SendOptions } from './types/main.js'
|
|
4
|
-
|
|
5
|
-
const DEFAULT_TIMEOUT = 5000
|
|
6
|
-
|
|
7
|
-
export class Publisher {
|
|
8
|
-
#config: MercureConfig
|
|
9
|
-
|
|
10
|
-
constructor(config: MercureConfig) {
|
|
11
|
-
this.#config = config
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
async publish(
|
|
15
|
-
topics: string | string[],
|
|
16
|
-
data: Record<string, unknown> = {},
|
|
17
|
-
options: SendOptions = {}
|
|
18
|
-
): Promise<Response> {
|
|
19
|
-
const topicList = Array.isArray(topics) ? topics : [topics]
|
|
20
|
-
const form = new URLSearchParams()
|
|
21
|
-
|
|
22
|
-
for (const topic of topicList) {
|
|
23
|
-
form.append('topic', topic)
|
|
24
|
-
}
|
|
25
|
-
form.append('data', JSON.stringify(data))
|
|
26
|
-
|
|
27
|
-
if (options.private) form.append('private', 'on')
|
|
28
|
-
if (options.id) form.append('id', options.id)
|
|
29
|
-
if (options.type) form.append('type', options.type)
|
|
30
|
-
if (options.retry !== undefined) form.append('retry', String(options.retry))
|
|
31
|
-
|
|
32
|
-
const timeout = this.#config.http?.timeout ?? DEFAULT_TIMEOUT
|
|
33
|
-
const controller = timeout > 0 ? new AbortController() : null
|
|
34
|
-
const timer = controller ? setTimeout(() => controller.abort(), timeout) : null
|
|
35
|
-
|
|
36
|
-
try {
|
|
37
|
-
const response = await fetch(this.#config.endpoint, {
|
|
38
|
-
method: 'POST',
|
|
39
|
-
headers: {
|
|
40
|
-
'Authorization': `Bearer ${this.#config.adminToken}`,
|
|
41
|
-
'Content-Type': 'application/x-www-form-urlencoded',
|
|
42
|
-
},
|
|
43
|
-
body: form.toString(),
|
|
44
|
-
signal: controller?.signal,
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
if (!response.ok) {
|
|
48
|
-
const body = await response.text()
|
|
49
|
-
throw new MercurePublishError(response.status, body)
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
return response
|
|
53
|
-
} catch (err) {
|
|
54
|
-
if ((err as Error)?.name === 'AbortError') {
|
|
55
|
-
throw new MercureTimeoutError(timeout)
|
|
56
|
-
}
|
|
57
|
-
throw err
|
|
58
|
-
} finally {
|
|
59
|
-
if (timer) clearTimeout(timer)
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
}
|
package/src/token_generator.ts
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import jws from 'jws'
|
|
2
|
-
import type { MercureConfig } from './types/main.js'
|
|
3
|
-
|
|
4
|
-
export class TokenGenerator {
|
|
5
|
-
#config: MercureConfig
|
|
6
|
-
|
|
7
|
-
constructor(config: MercureConfig) {
|
|
8
|
-
this.#config = config
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
generate(payload: Record<string, unknown>): Promise<string> {
|
|
12
|
-
return new Promise<string>((resolve, reject) => {
|
|
13
|
-
jws
|
|
14
|
-
.createSign({
|
|
15
|
-
payload: { mercure: payload },
|
|
16
|
-
secret: this.#config.jwt.secret,
|
|
17
|
-
header: { alg: this.#config.jwt.alg },
|
|
18
|
-
})
|
|
19
|
-
.on('error', reject)
|
|
20
|
-
.on('done', resolve)
|
|
21
|
-
})
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// WARNING: never pass user-controlled topics directly — a wildcard topic ('*')
|
|
25
|
-
// would grant the token holder access to all private events.
|
|
26
|
-
generateSubscribeToken(topics: string[]): Promise<string> {
|
|
27
|
-
return this.generate({ subscribe: topics })
|
|
28
|
-
}
|
|
29
|
-
}
|
package/src/types/extended.ts
DELETED
package/src/types/main.ts
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import type { Algorithm } from 'jws'
|
|
2
|
-
|
|
3
|
-
export interface MercureConfig {
|
|
4
|
-
endpoint: string
|
|
5
|
-
adminToken: string
|
|
6
|
-
jwt: {
|
|
7
|
-
alg: Algorithm
|
|
8
|
-
secret: string
|
|
9
|
-
}
|
|
10
|
-
http?: {
|
|
11
|
-
timeout?: number
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export interface SendOptions {
|
|
16
|
-
private?: boolean
|
|
17
|
-
id?: string
|
|
18
|
-
type?: string
|
|
19
|
-
retry?: number
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface MercureContract {
|
|
23
|
-
generate(payload: Record<string, unknown>): Promise<string>
|
|
24
|
-
generateSubscribeToken(topics: string[]): Promise<string>
|
|
25
|
-
send(
|
|
26
|
-
topics: string | string[],
|
|
27
|
-
data?: Record<string, unknown>,
|
|
28
|
-
options?: boolean | SendOptions
|
|
29
|
-
): Promise<Response>
|
|
30
|
-
ping(): Promise<boolean>
|
|
31
|
-
}
|