@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.
Files changed (42) hide show
  1. package/LICENSE.md +1 -1
  2. package/README.md +67 -7
  3. package/build/index.d.ts +3 -0
  4. package/build/index.js +1 -0
  5. package/build/src/define_config.js +23 -1
  6. package/build/src/errors/connection_error.d.ts +4 -0
  7. package/build/src/errors/connection_error.js +14 -0
  8. package/build/src/errors/mercure_error.d.ts +1 -1
  9. package/build/src/errors/mercure_error.js +2 -2
  10. package/build/src/fake_mercure.d.ts +5 -3
  11. package/build/src/fake_mercure.js +18 -4
  12. package/build/src/mercure.d.ts +3 -3
  13. package/build/src/mercure.js +7 -6
  14. package/build/src/publisher.js +5 -1
  15. package/build/src/token_generator.d.ts +3 -3
  16. package/build/src/token_generator.js +13 -6
  17. package/build/src/types/main.d.ts +8 -4
  18. package/package.json +4 -8
  19. package/build/bin/test.d.ts +0 -1
  20. package/build/bin/test.js +0 -8
  21. package/build/tests/fake_mercure.spec.d.ts +0 -1
  22. package/build/tests/fake_mercure.spec.js +0 -76
  23. package/build/tests/mercure.spec.d.ts +0 -1
  24. package/build/tests/mercure.spec.js +0 -84
  25. package/build/tests/publisher.spec.d.ts +0 -1
  26. package/build/tests/publisher.spec.js +0 -101
  27. package/build/tests/token_generator.spec.d.ts +0 -1
  28. package/build/tests/token_generator.spec.js +0 -29
  29. package/configure.ts +0 -41
  30. package/index.ts +0 -14
  31. package/providers/mercure_provider.ts +0 -23
  32. package/src/define_config.ts +0 -18
  33. package/src/errors/config_error.ts +0 -8
  34. package/src/errors/mercure_error.ts +0 -6
  35. package/src/errors/publish_error.ts +0 -14
  36. package/src/errors/timeout_error.ts +0 -8
  37. package/src/fake_mercure.ts +0 -74
  38. package/src/mercure.ts +0 -54
  39. package/src/publisher.ts +0 -62
  40. package/src/token_generator.ts +0 -29
  41. package/src/types/extended.ts +0 -7
  42. 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 the token to your frontend:
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 url = new URL('http://localhost:3000/.well-known/mercure')
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.toString(), {
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 is reachable, `false` on network error or timeout.
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 = ['HS256', 'HS384', 'HS512', 'RS256', 'RS384', 'RS512'];
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,4 @@
1
+ import { MercureError } from './mercure_error.js';
2
+ export declare class MercureConnectionError extends MercureError {
3
+ constructor(cause: unknown);
4
+ }
@@ -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,3 +1,3 @@
1
1
  export declare class MercureError extends Error {
2
- constructor(message: string);
2
+ constructor(message: string, options?: ErrorOptions);
3
3
  }
@@ -1,6 +1,6 @@
1
1
  export class MercureError extends Error {
2
- constructor(message) {
3
- super(message);
2
+ constructor(message, options) {
3
+ super(message, options);
4
4
  this.name = 'MercureError';
5
5
  }
6
6
  }
@@ -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>): Promise<string>;
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
- async generate(payload) {
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 JSON.stringify(msg.data) === JSON.stringify(data);
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 = [];
@@ -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>): Promise<string>;
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
  }
@@ -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
- return true;
32
+ // 4xx still means the hub answered
33
+ return response.status < 500;
33
34
  }
34
35
  catch {
35
36
  return false;
@@ -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>): Promise<string>;
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: { mercure: 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
- // WARNING: never pass user-controlled topics directly — a wildcard topic ('*')
20
- // would grant the token holder access to all private events.
21
- generateSubscribeToken(topics) {
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
- import type { Algorithm } from 'jws';
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: Algorithm;
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>): Promise<string>;
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.1.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+ssh://git@github.com/mdsiha/adonis-mercure.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"
@@ -1 +0,0 @@
1
- export {};
package/build/bin/test.js DELETED
@@ -1,8 +0,0 @@
1
- import { assert } from '@japa/assert';
2
- import { configure, processCLIArgs, run } from '@japa/runner';
3
- processCLIArgs(process.argv.splice(2));
4
- configure({
5
- files: ['tests/**/*.spec.ts'],
6
- plugins: [assert()],
7
- });
8
- run();
@@ -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
- }
@@ -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,8 +0,0 @@
1
- import { MercureError } from './mercure_error.js'
2
-
3
- export class MercureConfigError extends MercureError {
4
- constructor(message: string) {
5
- super(message)
6
- this.name = 'MercureConfigError'
7
- }
8
- }
@@ -1,6 +0,0 @@
1
- export class MercureError extends Error {
2
- constructor(message: string) {
3
- super(message)
4
- this.name = 'MercureError'
5
- }
6
- }
@@ -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
- }
@@ -1,8 +0,0 @@
1
- import { MercureError } from './mercure_error.js'
2
-
3
- export class MercureTimeoutError extends MercureError {
4
- constructor(readonly timeoutMs: number) {
5
- super(`Request to Mercure hub timed out after ${timeoutMs}ms`)
6
- this.name = 'MercureTimeoutError'
7
- }
8
- }
@@ -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
- }
@@ -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
- }
@@ -1,7 +0,0 @@
1
- import { Mercure } from '../mercure.js'
2
-
3
- declare module '@adonisjs/core/types' {
4
- export interface ContainerBindings {
5
- mercure: Mercure
6
- }
7
- }
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
- }