@marinade.finance/ts-subscription-client 1.0.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/README.md +82 -0
- package/__tests__/client.e2e.ts +245 -0
- package/__tests__/client.test.ts +300 -0
- package/__tests__/message.test.ts +30 -0
- package/client.ts +121 -0
- package/dist/client.d.ts +11 -0
- package/dist/client.js +83 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +15 -0
- package/dist/message.d.ts +6 -0
- package/dist/message.js +17 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/dist/types.d.ts +67 -0
- package/dist/types.js +12 -0
- package/index.ts +28 -0
- package/jest.config.js +10 -0
- package/message.ts +25 -0
- package/package.json +24 -0
- package/tsconfig.json +23 -0
- package/types.ts +81 -0
package/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# ts-subscription-client
|
|
2
|
+
|
|
3
|
+
TypeScript client for the Marinade notification subscription API.
|
|
4
|
+
Allows users to subscribe/unsubscribe to notifications and list
|
|
5
|
+
active subscriptions.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
import {
|
|
11
|
+
createSubscriptionClient,
|
|
12
|
+
subscribeMessage,
|
|
13
|
+
unsubscribeMessage,
|
|
14
|
+
listSubscriptionsMessage,
|
|
15
|
+
} from '@marinade.finance/ts-subscription-client'
|
|
16
|
+
|
|
17
|
+
const client = createSubscriptionClient({
|
|
18
|
+
base_url: 'https://notifications.marinade.finance',
|
|
19
|
+
})
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The message format is built by helper functions (`subscribeMessage`,
|
|
23
|
+
`unsubscribeMessage`, `listSubscriptionsMessage`). Each request
|
|
24
|
+
includes a signature and message for server-side authentication.
|
|
25
|
+
|
|
26
|
+
For local development and testing subscriptions end-to-end, see
|
|
27
|
+
[notification-service/DEV_GUIDE.md](../notification-service/DEV_GUIDE.md).
|
|
28
|
+
|
|
29
|
+
## Channels
|
|
30
|
+
|
|
31
|
+
A "channel" is a delivery medium for notifications. Each subscription
|
|
32
|
+
binds a user to a channel + address pair.
|
|
33
|
+
|
|
34
|
+
### telegram
|
|
35
|
+
|
|
36
|
+
Telegram subscriptions use a two-phase activation flow:
|
|
37
|
+
|
|
38
|
+
1. **Subscribe** — the client sends a `POST /subscriptions` request
|
|
39
|
+
with `channel=telegram`. The server creates a pending activation
|
|
40
|
+
record and returns a `deep_link` in the response
|
|
41
|
+
(e.g., `https://t.me/MarinadeBot?start=<token>`).
|
|
42
|
+
At this point the subscription exists but cannot receive messages.
|
|
43
|
+
|
|
44
|
+
2. **Activate** — the user opens the deep link in Telegram and
|
|
45
|
+
presses "Start". The bot receives the token via its webhook,
|
|
46
|
+
links the user's Telegram chat to the subscription, and confirms
|
|
47
|
+
activation. From this point on, notifications are delivered as
|
|
48
|
+
Telegram messages.
|
|
49
|
+
|
|
50
|
+
If the user has already activated, the response returns
|
|
51
|
+
`telegram_status: 'already_activated'` instead of a new deep link.
|
|
52
|
+
|
|
53
|
+
Blocking the bot automatically unsubscribes all Telegram
|
|
54
|
+
subscriptions for that chat.
|
|
55
|
+
|
|
56
|
+
### api
|
|
57
|
+
|
|
58
|
+
The API channel stores notifications in a server-side outbox.
|
|
59
|
+
Clients pull them via the read API (notifications endpoint).
|
|
60
|
+
No activation step is needed — subscribing is sufficient.
|
|
61
|
+
|
|
62
|
+
## Notification types
|
|
63
|
+
|
|
64
|
+
### bonds
|
|
65
|
+
|
|
66
|
+
Bond risk notifications for validators in the SAM auction.
|
|
67
|
+
Subscribing requires proof of authority over a bond — the signer
|
|
68
|
+
must be the bond authority, validator identity, or vote account
|
|
69
|
+
owner. The server verifies this against on-chain bond accounts.
|
|
70
|
+
|
|
71
|
+
The `additional_data` field for subscribe/unsubscribe should include:
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
{
|
|
75
|
+
config_address: string // bonds program config
|
|
76
|
+
vote_account: string // validator vote account
|
|
77
|
+
bond_pubkey: string // derived bond PDA
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
For listing subscriptions (`GET /subscriptions`), no `additional_data`
|
|
82
|
+
is needed — the server performs a reverse lookup from the pubkey.
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import http from 'http'
|
|
2
|
+
|
|
3
|
+
import { createSubscriptionClient, NetworkError } from '../index'
|
|
4
|
+
|
|
5
|
+
import type { SubscriptionClient } from '../index'
|
|
6
|
+
import type { IncomingMessage, ServerResponse } from 'http'
|
|
7
|
+
|
|
8
|
+
let server: http.Server
|
|
9
|
+
let port: number
|
|
10
|
+
let client: SubscriptionClient
|
|
11
|
+
|
|
12
|
+
function startServer(
|
|
13
|
+
handler: (req: IncomingMessage, res: ServerResponse) => void,
|
|
14
|
+
): Promise<void> {
|
|
15
|
+
return new Promise(resolve => {
|
|
16
|
+
server = http.createServer(handler)
|
|
17
|
+
server.listen(0, () => {
|
|
18
|
+
const addr = server.address()
|
|
19
|
+
if (addr && typeof addr !== 'string') {
|
|
20
|
+
port = addr.port
|
|
21
|
+
}
|
|
22
|
+
client = createSubscriptionClient({
|
|
23
|
+
base_url: `http://localhost:${port}`,
|
|
24
|
+
})
|
|
25
|
+
resolve()
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function stopServer(): Promise<void> {
|
|
31
|
+
return new Promise(resolve => {
|
|
32
|
+
server.close(() => resolve())
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
37
|
+
return new Promise(resolve => {
|
|
38
|
+
let data = ''
|
|
39
|
+
req.on('data', (chunk: Buffer) => {
|
|
40
|
+
data += chunk.toString()
|
|
41
|
+
})
|
|
42
|
+
req.on('end', () => resolve(data))
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('SubscriptionClient E2E', () => {
|
|
47
|
+
afterEach(async () => {
|
|
48
|
+
if (server) {
|
|
49
|
+
await stopServer()
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('subscribe', () => {
|
|
54
|
+
it('sends POST with correct body and returns typed response', async () => {
|
|
55
|
+
let receivedBody: Record<string, unknown> | undefined
|
|
56
|
+
let receivedMethod: string | undefined
|
|
57
|
+
|
|
58
|
+
await startServer((req, res) => {
|
|
59
|
+
receivedMethod = req.method
|
|
60
|
+
void readBody(req).then(body => {
|
|
61
|
+
receivedBody = JSON.parse(body) as Record<string, unknown>
|
|
62
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
63
|
+
res.end(
|
|
64
|
+
JSON.stringify({
|
|
65
|
+
user_id: 'u1',
|
|
66
|
+
notification_type: 'bonds',
|
|
67
|
+
channel: 'telegram',
|
|
68
|
+
channel_address: '@test',
|
|
69
|
+
created_at: '2024-01-01',
|
|
70
|
+
deep_link: 'https://t.me/bot?start=abc',
|
|
71
|
+
}),
|
|
72
|
+
)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const result = await client.subscribe({
|
|
77
|
+
pubkey: 'pk1',
|
|
78
|
+
notification_type: 'bonds',
|
|
79
|
+
channel: 'telegram',
|
|
80
|
+
channel_address: '@test',
|
|
81
|
+
signature: 'sig1',
|
|
82
|
+
message: 'Subscribe bonds telegram 1710000000',
|
|
83
|
+
additional_data: { vote_account: 'va1' },
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
expect(receivedMethod).toBe('POST')
|
|
87
|
+
expect(receivedBody).toEqual({
|
|
88
|
+
pubkey: 'pk1',
|
|
89
|
+
notification_type: 'bonds',
|
|
90
|
+
channel: 'telegram',
|
|
91
|
+
channel_address: '@test',
|
|
92
|
+
signature: 'sig1',
|
|
93
|
+
message: 'Subscribe bonds telegram 1710000000',
|
|
94
|
+
additional_data: { vote_account: 'va1' },
|
|
95
|
+
})
|
|
96
|
+
expect(result.deep_link).toBe('https://t.me/bot?start=abc')
|
|
97
|
+
expect(result.user_id).toBe('u1')
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('unsubscribe', () => {
|
|
102
|
+
it('sends DELETE with correct body', async () => {
|
|
103
|
+
let receivedMethod: string | undefined
|
|
104
|
+
let receivedBody: Record<string, unknown> | undefined
|
|
105
|
+
|
|
106
|
+
await startServer((req, res) => {
|
|
107
|
+
receivedMethod = req.method
|
|
108
|
+
void readBody(req).then(body => {
|
|
109
|
+
receivedBody = JSON.parse(body) as Record<string, unknown>
|
|
110
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
111
|
+
res.end(JSON.stringify({ deleted: true }))
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
const result = await client.unsubscribe({
|
|
116
|
+
pubkey: 'pk1',
|
|
117
|
+
notification_type: 'bonds',
|
|
118
|
+
channel: 'telegram',
|
|
119
|
+
signature: 'sig1',
|
|
120
|
+
message: 'Unsubscribe bonds telegram 1710000000',
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
expect(receivedMethod).toBe('DELETE')
|
|
124
|
+
expect(receivedBody?.channel_address).toBeUndefined()
|
|
125
|
+
expect(result.deleted).toBe(true)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('sends DELETE with channel_address when provided', async () => {
|
|
129
|
+
let receivedBody: Record<string, unknown> | undefined
|
|
130
|
+
|
|
131
|
+
await startServer((req, res) => {
|
|
132
|
+
void readBody(req).then(body => {
|
|
133
|
+
receivedBody = JSON.parse(body) as Record<string, unknown>
|
|
134
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
135
|
+
res.end(JSON.stringify({ deleted: true }))
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
await client.unsubscribe({
|
|
140
|
+
pubkey: 'pk1',
|
|
141
|
+
notification_type: 'bonds',
|
|
142
|
+
channel: 'telegram',
|
|
143
|
+
channel_address: '@testuser',
|
|
144
|
+
signature: 'sig1',
|
|
145
|
+
message: 'Unsubscribe bonds telegram 1710000000',
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
expect(receivedBody?.channel_address).toBe('@testuser')
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
describe('listSubscriptions', () => {
|
|
153
|
+
it('sends GET with correct query params and auth headers', async () => {
|
|
154
|
+
let receivedUrl: string | undefined
|
|
155
|
+
let receivedHeaders: Record<string, string | undefined> | undefined
|
|
156
|
+
|
|
157
|
+
await startServer((req, res) => {
|
|
158
|
+
receivedUrl = req.url
|
|
159
|
+
receivedHeaders = {
|
|
160
|
+
'x-solana-signature': req.headers['x-solana-signature'] as string,
|
|
161
|
+
'x-solana-message': req.headers['x-solana-message'] as string,
|
|
162
|
+
}
|
|
163
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
164
|
+
res.end(
|
|
165
|
+
JSON.stringify([
|
|
166
|
+
{
|
|
167
|
+
user_id: 'u1',
|
|
168
|
+
notification_type: 'bonds',
|
|
169
|
+
channel: 'telegram',
|
|
170
|
+
channel_address: '@test',
|
|
171
|
+
created_at: '2024-01-01',
|
|
172
|
+
},
|
|
173
|
+
]),
|
|
174
|
+
)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
const result = await client.listSubscriptions(
|
|
178
|
+
{
|
|
179
|
+
pubkey: 'pk1',
|
|
180
|
+
notification_type: 'bonds',
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
signature: 'sig1',
|
|
184
|
+
message: 'ListSubscriptions pk1 1710000000',
|
|
185
|
+
},
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
expect(receivedUrl).toBe(
|
|
189
|
+
'/subscriptions?pubkey=pk1¬ification_type=bonds',
|
|
190
|
+
)
|
|
191
|
+
expect(receivedHeaders).toEqual({
|
|
192
|
+
'x-solana-signature': 'sig1',
|
|
193
|
+
'x-solana-message': 'ListSubscriptions pk1 1710000000',
|
|
194
|
+
})
|
|
195
|
+
expect(result).toHaveLength(1)
|
|
196
|
+
expect(result[0].channel).toBe('telegram')
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
describe('error propagation', () => {
|
|
201
|
+
it.each([400, 401, 403, 404])(
|
|
202
|
+
'throws NetworkError with status %d',
|
|
203
|
+
async statusCode => {
|
|
204
|
+
await startServer((_req, res) => {
|
|
205
|
+
res.writeHead(statusCode, { 'Content-Type': 'text/plain' })
|
|
206
|
+
res.end(`error ${statusCode}`)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
const error = await client
|
|
210
|
+
.subscribe({
|
|
211
|
+
pubkey: 'p',
|
|
212
|
+
notification_type: 'bonds',
|
|
213
|
+
channel: 'telegram',
|
|
214
|
+
channel_address: '@t',
|
|
215
|
+
signature: 's',
|
|
216
|
+
message: 'm',
|
|
217
|
+
})
|
|
218
|
+
.catch((e: unknown) => e)
|
|
219
|
+
|
|
220
|
+
expect(error).toBeInstanceOf(NetworkError)
|
|
221
|
+
expect((error as NetworkError).status).toBe(statusCode)
|
|
222
|
+
expect((error as NetworkError).response).toBe(`error ${statusCode}`)
|
|
223
|
+
},
|
|
224
|
+
)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
describe('connection refused', () => {
|
|
228
|
+
it('throws NetworkError when server is unreachable', async () => {
|
|
229
|
+
const unreachableClient = createSubscriptionClient({
|
|
230
|
+
base_url: 'http://localhost:19999',
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
await expect(
|
|
234
|
+
unreachableClient.subscribe({
|
|
235
|
+
pubkey: 'p',
|
|
236
|
+
notification_type: 'bonds',
|
|
237
|
+
channel: 'telegram',
|
|
238
|
+
channel_address: '@t',
|
|
239
|
+
signature: 's',
|
|
240
|
+
message: 'm',
|
|
241
|
+
}),
|
|
242
|
+
).rejects.toThrow(NetworkError)
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
})
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { createSubscriptionClient, NetworkError } from '../index'
|
|
2
|
+
|
|
3
|
+
global.fetch = jest.fn()
|
|
4
|
+
|
|
5
|
+
describe('SubscriptionClient', () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
jest.clearAllMocks()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
jest.restoreAllMocks()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
describe('subscribe', () => {
|
|
15
|
+
it('should send POST request with correct body', async () => {
|
|
16
|
+
const mockFetch = global.fetch as jest.Mock
|
|
17
|
+
const responseBody = {
|
|
18
|
+
user_id: 'user-1',
|
|
19
|
+
notification_type: 'bonds',
|
|
20
|
+
channel: 'telegram',
|
|
21
|
+
channel_address: '@testuser',
|
|
22
|
+
created_at: '2024-01-01T00:00:00Z',
|
|
23
|
+
deep_link: 'https://t.me/test',
|
|
24
|
+
}
|
|
25
|
+
mockFetch.mockResolvedValue({
|
|
26
|
+
ok: true,
|
|
27
|
+
json: () => Promise.resolve(responseBody),
|
|
28
|
+
} as Response)
|
|
29
|
+
|
|
30
|
+
const client = createSubscriptionClient({
|
|
31
|
+
base_url: 'http://localhost:3000',
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const request = {
|
|
35
|
+
pubkey: 'pubkey123',
|
|
36
|
+
notification_type: 'bonds',
|
|
37
|
+
channel: 'telegram',
|
|
38
|
+
channel_address: '@testuser',
|
|
39
|
+
signature: 'sig123',
|
|
40
|
+
message: 'Subscribe bonds telegram 1710000000',
|
|
41
|
+
additional_data: { vote_account: 'vote123' },
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const result = await client.subscribe(request)
|
|
45
|
+
|
|
46
|
+
expect(result).toEqual(responseBody)
|
|
47
|
+
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
48
|
+
const [url, options] = mockFetch.mock.calls[0]
|
|
49
|
+
expect(url).toBe('http://localhost:3000/subscriptions')
|
|
50
|
+
expect(options?.method).toBe('POST')
|
|
51
|
+
expect(options?.headers).toEqual({ 'Content-Type': 'application/json' })
|
|
52
|
+
expect(JSON.parse(options?.body as string)).toEqual(request)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('should throw NetworkError on HTTP error', async () => {
|
|
56
|
+
const mockFetch = global.fetch as jest.Mock
|
|
57
|
+
mockFetch.mockResolvedValue({
|
|
58
|
+
ok: false,
|
|
59
|
+
status: 400,
|
|
60
|
+
statusText: 'Bad Request',
|
|
61
|
+
text: () => Promise.resolve('validation failed'),
|
|
62
|
+
} as Response)
|
|
63
|
+
|
|
64
|
+
const client = createSubscriptionClient({
|
|
65
|
+
base_url: 'http://localhost:3000',
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
await expect(
|
|
69
|
+
client.subscribe({
|
|
70
|
+
pubkey: 'p',
|
|
71
|
+
notification_type: 'bonds',
|
|
72
|
+
channel: 'telegram',
|
|
73
|
+
channel_address: '@test',
|
|
74
|
+
signature: 's',
|
|
75
|
+
message: 'm',
|
|
76
|
+
}),
|
|
77
|
+
).rejects.toThrow(NetworkError)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('should throw NetworkError on network failure', async () => {
|
|
81
|
+
const mockFetch = global.fetch as jest.Mock
|
|
82
|
+
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'))
|
|
83
|
+
|
|
84
|
+
const client = createSubscriptionClient({
|
|
85
|
+
base_url: 'http://localhost:3000',
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
await expect(
|
|
89
|
+
client.subscribe({
|
|
90
|
+
pubkey: 'p',
|
|
91
|
+
notification_type: 'bonds',
|
|
92
|
+
channel: 'telegram',
|
|
93
|
+
channel_address: '@test',
|
|
94
|
+
signature: 's',
|
|
95
|
+
message: 'm',
|
|
96
|
+
}),
|
|
97
|
+
).rejects.toThrow('ECONNREFUSED')
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('unsubscribe', () => {
|
|
102
|
+
it('should send DELETE request with correct body', async () => {
|
|
103
|
+
const mockFetch = global.fetch as jest.Mock
|
|
104
|
+
mockFetch.mockResolvedValue({
|
|
105
|
+
ok: true,
|
|
106
|
+
json: () => Promise.resolve({ deleted: true }),
|
|
107
|
+
} as Response)
|
|
108
|
+
|
|
109
|
+
const client = createSubscriptionClient({
|
|
110
|
+
base_url: 'http://localhost:3000',
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const request = {
|
|
114
|
+
pubkey: 'pubkey123',
|
|
115
|
+
notification_type: 'bonds',
|
|
116
|
+
channel: 'telegram',
|
|
117
|
+
signature: 'sig123',
|
|
118
|
+
message: 'Unsubscribe bonds telegram 1710000000',
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const result = await client.unsubscribe(request)
|
|
122
|
+
|
|
123
|
+
expect(result).toEqual({ deleted: true })
|
|
124
|
+
const [url, options] = mockFetch.mock.calls[0]
|
|
125
|
+
expect(url).toBe('http://localhost:3000/subscriptions')
|
|
126
|
+
expect(options?.method).toBe('DELETE')
|
|
127
|
+
expect(JSON.parse(options?.body as string)).toEqual(request)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('should include channel_address when provided', async () => {
|
|
131
|
+
const mockFetch = global.fetch as jest.Mock
|
|
132
|
+
mockFetch.mockResolvedValue({
|
|
133
|
+
ok: true,
|
|
134
|
+
json: () => Promise.resolve({ deleted: true }),
|
|
135
|
+
} as Response)
|
|
136
|
+
|
|
137
|
+
const client = createSubscriptionClient({
|
|
138
|
+
base_url: 'http://localhost:3000',
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
const request = {
|
|
142
|
+
pubkey: 'pubkey123',
|
|
143
|
+
notification_type: 'bonds',
|
|
144
|
+
channel: 'telegram',
|
|
145
|
+
channel_address: '@testuser',
|
|
146
|
+
signature: 'sig123',
|
|
147
|
+
message: 'Unsubscribe bonds telegram 1710000000',
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
await client.unsubscribe(request)
|
|
151
|
+
|
|
152
|
+
const [, options] = mockFetch.mock.calls[0]
|
|
153
|
+
const body = JSON.parse(options?.body as string)
|
|
154
|
+
expect(body.channel_address).toBe('@testuser')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('should throw NetworkError on HTTP error', async () => {
|
|
158
|
+
const mockFetch = global.fetch as jest.Mock
|
|
159
|
+
mockFetch.mockResolvedValue({
|
|
160
|
+
ok: false,
|
|
161
|
+
status: 403,
|
|
162
|
+
statusText: 'Forbidden',
|
|
163
|
+
text: () => Promise.resolve('unauthorized'),
|
|
164
|
+
} as Response)
|
|
165
|
+
|
|
166
|
+
const client = createSubscriptionClient({
|
|
167
|
+
base_url: 'http://localhost:3000',
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
await expect(
|
|
171
|
+
client.unsubscribe({
|
|
172
|
+
pubkey: 'p',
|
|
173
|
+
notification_type: 'bonds',
|
|
174
|
+
channel: 'telegram',
|
|
175
|
+
signature: 's',
|
|
176
|
+
message: 'm',
|
|
177
|
+
}),
|
|
178
|
+
).rejects.toThrow(NetworkError)
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('listSubscriptions', () => {
|
|
183
|
+
it('should send GET request with correct query params and auth headers', async () => {
|
|
184
|
+
const mockFetch = global.fetch as jest.Mock
|
|
185
|
+
const responseBody = [
|
|
186
|
+
{
|
|
187
|
+
user_id: 'user-1',
|
|
188
|
+
notification_type: 'bonds',
|
|
189
|
+
channel: 'telegram',
|
|
190
|
+
channel_address: '@testuser',
|
|
191
|
+
created_at: '2024-01-01T00:00:00Z',
|
|
192
|
+
},
|
|
193
|
+
]
|
|
194
|
+
mockFetch.mockResolvedValue({
|
|
195
|
+
ok: true,
|
|
196
|
+
json: () => Promise.resolve(responseBody),
|
|
197
|
+
} as Response)
|
|
198
|
+
|
|
199
|
+
const client = createSubscriptionClient({
|
|
200
|
+
base_url: 'http://localhost:3000',
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
const result = await client.listSubscriptions(
|
|
204
|
+
{
|
|
205
|
+
pubkey: 'pubkey123',
|
|
206
|
+
notification_type: 'bonds',
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
signature: 'sig123',
|
|
210
|
+
message: 'ListSubscriptions pubkey123 1710000000',
|
|
211
|
+
},
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
expect(result).toEqual(responseBody)
|
|
215
|
+
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
216
|
+
const [url, options] = mockFetch.mock.calls[0]
|
|
217
|
+
expect(url).toBe(
|
|
218
|
+
'http://localhost:3000/subscriptions?pubkey=pubkey123¬ification_type=bonds',
|
|
219
|
+
)
|
|
220
|
+
expect(options?.method).toBe('GET')
|
|
221
|
+
expect(options?.headers).toEqual({
|
|
222
|
+
'x-solana-signature': 'sig123',
|
|
223
|
+
'x-solana-message': 'ListSubscriptions pubkey123 1710000000',
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('should omit optional query params when not provided', async () => {
|
|
228
|
+
const mockFetch = global.fetch as jest.Mock
|
|
229
|
+
mockFetch.mockResolvedValue({
|
|
230
|
+
ok: true,
|
|
231
|
+
json: () => Promise.resolve([]),
|
|
232
|
+
} as Response)
|
|
233
|
+
|
|
234
|
+
const client = createSubscriptionClient({
|
|
235
|
+
base_url: 'http://localhost:3000',
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
await client.listSubscriptions(
|
|
239
|
+
{ pubkey: 'pubkey123' },
|
|
240
|
+
{ signature: 'sig', message: 'msg' },
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
const [url] = mockFetch.mock.calls[0]
|
|
244
|
+
expect(url).toBe('http://localhost:3000/subscriptions?pubkey=pubkey123')
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('should throw NetworkError on HTTP error', async () => {
|
|
248
|
+
const mockFetch = global.fetch as jest.Mock
|
|
249
|
+
mockFetch.mockResolvedValue({
|
|
250
|
+
ok: false,
|
|
251
|
+
status: 401,
|
|
252
|
+
statusText: 'Unauthorized',
|
|
253
|
+
text: () => Promise.resolve('invalid signature'),
|
|
254
|
+
} as Response)
|
|
255
|
+
|
|
256
|
+
const client = createSubscriptionClient({
|
|
257
|
+
base_url: 'http://localhost:3000',
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
await expect(
|
|
261
|
+
client.listSubscriptions(
|
|
262
|
+
{ pubkey: 'p' },
|
|
263
|
+
{ signature: 's', message: 'm' },
|
|
264
|
+
),
|
|
265
|
+
).rejects.toThrow(NetworkError)
|
|
266
|
+
})
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
describe('timeout', () => {
|
|
270
|
+
it('should throw NetworkError on timeout', async () => {
|
|
271
|
+
const mockFetch = global.fetch as jest.Mock
|
|
272
|
+
mockFetch.mockImplementation(
|
|
273
|
+
(_url: string, options: { signal: AbortSignal }) =>
|
|
274
|
+
new Promise((_resolve, reject) => {
|
|
275
|
+
options.signal.addEventListener('abort', () => {
|
|
276
|
+
const err = new Error('The operation was aborted')
|
|
277
|
+
err.name = 'AbortError'
|
|
278
|
+
reject(err)
|
|
279
|
+
})
|
|
280
|
+
}),
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
const client = createSubscriptionClient({
|
|
284
|
+
base_url: 'http://localhost:3000',
|
|
285
|
+
timeout_ms: 50,
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
await expect(
|
|
289
|
+
client.subscribe({
|
|
290
|
+
pubkey: 'p',
|
|
291
|
+
notification_type: 'bonds',
|
|
292
|
+
channel: 'telegram',
|
|
293
|
+
channel_address: '@test',
|
|
294
|
+
signature: 's',
|
|
295
|
+
message: 'm',
|
|
296
|
+
}),
|
|
297
|
+
).rejects.toThrow(/timeout/)
|
|
298
|
+
})
|
|
299
|
+
})
|
|
300
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import {
|
|
2
|
+
subscribeMessage,
|
|
3
|
+
unsubscribeMessage,
|
|
4
|
+
listSubscriptionsMessage,
|
|
5
|
+
} from '../index'
|
|
6
|
+
|
|
7
|
+
describe('message helpers', () => {
|
|
8
|
+
it('subscribeMessage', () => {
|
|
9
|
+
expect(subscribeMessage('bonds', 'telegram', 1710000000)).toBe(
|
|
10
|
+
'Subscribe bonds telegram 1710000000',
|
|
11
|
+
)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('unsubscribeMessage', () => {
|
|
15
|
+
expect(unsubscribeMessage('bonds', 'email', 1710000000)).toBe(
|
|
16
|
+
'Unsubscribe bonds email 1710000000',
|
|
17
|
+
)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('listSubscriptionsMessage', () => {
|
|
21
|
+
expect(
|
|
22
|
+
listSubscriptionsMessage(
|
|
23
|
+
'GrxB8UaaaaaaaaaaaaaaAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
24
|
+
1710000000,
|
|
25
|
+
),
|
|
26
|
+
).toBe(
|
|
27
|
+
'ListSubscriptions GrxB8UaaaaaaaaaaaaaaAAAAAAAAAAAAAAAAAAAAAAAA 1710000000',
|
|
28
|
+
)
|
|
29
|
+
})
|
|
30
|
+
})
|
package/client.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type SubscriptionClientConfig,
|
|
3
|
+
type Logger,
|
|
4
|
+
type SubscribeRequest,
|
|
5
|
+
type SubscribeResponse,
|
|
6
|
+
type UnsubscribeRequest,
|
|
7
|
+
type UnsubscribeResponse,
|
|
8
|
+
type ListSubscriptionsQuery,
|
|
9
|
+
type ListSubscriptionsAuth,
|
|
10
|
+
type Subscription,
|
|
11
|
+
NetworkError,
|
|
12
|
+
} from './types'
|
|
13
|
+
|
|
14
|
+
const PATH_SUBSCRIPTIONS = '/subscriptions'
|
|
15
|
+
|
|
16
|
+
export class SubscriptionClient {
|
|
17
|
+
private readonly base_url: string
|
|
18
|
+
private readonly timeout_ms: number
|
|
19
|
+
private readonly logger?: Logger
|
|
20
|
+
|
|
21
|
+
constructor(config: SubscriptionClientConfig) {
|
|
22
|
+
this.base_url = config.base_url
|
|
23
|
+
this.timeout_ms = config.timeout_ms ?? 10_000
|
|
24
|
+
this.logger = config.logger
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async subscribe(request: SubscribeRequest): Promise<SubscribeResponse> {
|
|
28
|
+
this.logger?.debug(
|
|
29
|
+
`POST ${PATH_SUBSCRIPTIONS} body: ${JSON.stringify({
|
|
30
|
+
...request,
|
|
31
|
+
signature: '[redacted]',
|
|
32
|
+
})}`,
|
|
33
|
+
)
|
|
34
|
+
return this.fetch<SubscribeResponse>(PATH_SUBSCRIPTIONS, {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37
|
+
body: JSON.stringify(request),
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async unsubscribe(request: UnsubscribeRequest): Promise<UnsubscribeResponse> {
|
|
42
|
+
this.logger?.debug(
|
|
43
|
+
`DELETE ${PATH_SUBSCRIPTIONS} body: ${JSON.stringify({
|
|
44
|
+
...request,
|
|
45
|
+
signature: '[redacted]',
|
|
46
|
+
})}`,
|
|
47
|
+
)
|
|
48
|
+
return this.fetch<UnsubscribeResponse>(PATH_SUBSCRIPTIONS, {
|
|
49
|
+
method: 'DELETE',
|
|
50
|
+
headers: { 'Content-Type': 'application/json' },
|
|
51
|
+
body: JSON.stringify(request),
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async listSubscriptions(
|
|
56
|
+
query: ListSubscriptionsQuery,
|
|
57
|
+
auth: ListSubscriptionsAuth,
|
|
58
|
+
): Promise<Subscription[]> {
|
|
59
|
+
const params = new URLSearchParams()
|
|
60
|
+
params.set('pubkey', query.pubkey)
|
|
61
|
+
if (query.notification_type) {
|
|
62
|
+
params.set('notification_type', query.notification_type)
|
|
63
|
+
}
|
|
64
|
+
const path = `${PATH_SUBSCRIPTIONS}?${params.toString()}`
|
|
65
|
+
|
|
66
|
+
this.logger?.debug(`GET ${path} signature: [redacted]`)
|
|
67
|
+
return this.fetch<Subscription[]>(path, {
|
|
68
|
+
method: 'GET',
|
|
69
|
+
headers: {
|
|
70
|
+
'x-solana-signature': auth.signature,
|
|
71
|
+
'x-solana-message': auth.message,
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private async fetch<T>(path: string, init: RequestInit): Promise<T> {
|
|
77
|
+
const url = `${this.base_url}${path}`
|
|
78
|
+
const method = init.method ?? 'GET'
|
|
79
|
+
|
|
80
|
+
const controller = new AbortController()
|
|
81
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout_ms)
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const response = await globalThis.fetch(url, {
|
|
85
|
+
...init,
|
|
86
|
+
signal: controller.signal,
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
clearTimeout(timeoutId)
|
|
90
|
+
|
|
91
|
+
if (!response.ok) {
|
|
92
|
+
const errorBody = await response.text().catch(() => 'unknown error')
|
|
93
|
+
throw new NetworkError(
|
|
94
|
+
`${method} ${path} failed: ` +
|
|
95
|
+
`${response.status} ${response.statusText}`,
|
|
96
|
+
response.status,
|
|
97
|
+
errorBody,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return (await response.json()) as T
|
|
102
|
+
} catch (error) {
|
|
103
|
+
clearTimeout(timeoutId)
|
|
104
|
+
|
|
105
|
+
if (error instanceof NetworkError) {
|
|
106
|
+
throw error
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (error instanceof Error) {
|
|
110
|
+
if (error.name === 'AbortError') {
|
|
111
|
+
throw new NetworkError(
|
|
112
|
+
`${method} ${path} timeout after ${this.timeout_ms}ms`,
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
throw new NetworkError(`${method} ${path} failed: ${error.message}`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
throw new NetworkError(`${method} ${path} failed: unknown error`)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type SubscriptionClientConfig, type SubscribeRequest, type SubscribeResponse, type UnsubscribeRequest, type UnsubscribeResponse, type ListSubscriptionsQuery, type ListSubscriptionsAuth, type Subscription } from './types';
|
|
2
|
+
export declare class SubscriptionClient {
|
|
3
|
+
private readonly base_url;
|
|
4
|
+
private readonly timeout_ms;
|
|
5
|
+
private readonly logger?;
|
|
6
|
+
constructor(config: SubscriptionClientConfig);
|
|
7
|
+
subscribe(request: SubscribeRequest): Promise<SubscribeResponse>;
|
|
8
|
+
unsubscribe(request: UnsubscribeRequest): Promise<UnsubscribeResponse>;
|
|
9
|
+
listSubscriptions(query: ListSubscriptionsQuery, auth: ListSubscriptionsAuth): Promise<Subscription[]>;
|
|
10
|
+
private fetch;
|
|
11
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SubscriptionClient = void 0;
|
|
4
|
+
const types_1 = require("./types");
|
|
5
|
+
const PATH_SUBSCRIPTIONS = '/subscriptions';
|
|
6
|
+
class SubscriptionClient {
|
|
7
|
+
constructor(config) {
|
|
8
|
+
this.base_url = config.base_url;
|
|
9
|
+
this.timeout_ms = config.timeout_ms ?? 10000;
|
|
10
|
+
this.logger = config.logger;
|
|
11
|
+
}
|
|
12
|
+
async subscribe(request) {
|
|
13
|
+
this.logger?.debug(`POST ${PATH_SUBSCRIPTIONS} body: ${JSON.stringify({
|
|
14
|
+
...request,
|
|
15
|
+
signature: '[redacted]',
|
|
16
|
+
})}`);
|
|
17
|
+
return this.fetch(PATH_SUBSCRIPTIONS, {
|
|
18
|
+
method: 'POST',
|
|
19
|
+
headers: { 'Content-Type': 'application/json' },
|
|
20
|
+
body: JSON.stringify(request),
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
async unsubscribe(request) {
|
|
24
|
+
this.logger?.debug(`DELETE ${PATH_SUBSCRIPTIONS} body: ${JSON.stringify({
|
|
25
|
+
...request,
|
|
26
|
+
signature: '[redacted]',
|
|
27
|
+
})}`);
|
|
28
|
+
return this.fetch(PATH_SUBSCRIPTIONS, {
|
|
29
|
+
method: 'DELETE',
|
|
30
|
+
headers: { 'Content-Type': 'application/json' },
|
|
31
|
+
body: JSON.stringify(request),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
async listSubscriptions(query, auth) {
|
|
35
|
+
const params = new URLSearchParams();
|
|
36
|
+
params.set('pubkey', query.pubkey);
|
|
37
|
+
if (query.notification_type) {
|
|
38
|
+
params.set('notification_type', query.notification_type);
|
|
39
|
+
}
|
|
40
|
+
const path = `${PATH_SUBSCRIPTIONS}?${params.toString()}`;
|
|
41
|
+
this.logger?.debug(`GET ${path} signature: [redacted]`);
|
|
42
|
+
return this.fetch(path, {
|
|
43
|
+
method: 'GET',
|
|
44
|
+
headers: {
|
|
45
|
+
'x-solana-signature': auth.signature,
|
|
46
|
+
'x-solana-message': auth.message,
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
async fetch(path, init) {
|
|
51
|
+
const url = `${this.base_url}${path}`;
|
|
52
|
+
const method = init.method ?? 'GET';
|
|
53
|
+
const controller = new AbortController();
|
|
54
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout_ms);
|
|
55
|
+
try {
|
|
56
|
+
const response = await globalThis.fetch(url, {
|
|
57
|
+
...init,
|
|
58
|
+
signal: controller.signal,
|
|
59
|
+
});
|
|
60
|
+
clearTimeout(timeoutId);
|
|
61
|
+
if (!response.ok) {
|
|
62
|
+
const errorBody = await response.text().catch(() => 'unknown error');
|
|
63
|
+
throw new types_1.NetworkError(`${method} ${path} failed: ` +
|
|
64
|
+
`${response.status} ${response.statusText}`, response.status, errorBody);
|
|
65
|
+
}
|
|
66
|
+
return (await response.json());
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
clearTimeout(timeoutId);
|
|
70
|
+
if (error instanceof types_1.NetworkError) {
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
if (error instanceof Error) {
|
|
74
|
+
if (error.name === 'AbortError') {
|
|
75
|
+
throw new types_1.NetworkError(`${method} ${path} timeout after ${this.timeout_ms}ms`);
|
|
76
|
+
}
|
|
77
|
+
throw new types_1.NetworkError(`${method} ${path} failed: ${error.message}`);
|
|
78
|
+
}
|
|
79
|
+
throw new types_1.NetworkError(`${method} ${path} failed: unknown error`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
exports.SubscriptionClient = SubscriptionClient;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { SubscriptionClient } from './client';
|
|
2
|
+
import type { SubscriptionClientConfig } from './types';
|
|
3
|
+
export { SubscriptionClient };
|
|
4
|
+
export { subscribeMessage, unsubscribeMessage, listSubscriptionsMessage, } from './message';
|
|
5
|
+
export { NetworkError } from './types';
|
|
6
|
+
export type { Logger, SubscriptionClientConfig, SubscribeRequest, SubscribeResponse, UnsubscribeRequest, UnsubscribeResponse, ListSubscriptionsQuery, ListSubscriptionsAuth, Subscription, } from './types';
|
|
7
|
+
export declare function createSubscriptionClient(config: SubscriptionClientConfig): SubscriptionClient;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.NetworkError = exports.listSubscriptionsMessage = exports.unsubscribeMessage = exports.subscribeMessage = exports.SubscriptionClient = void 0;
|
|
4
|
+
exports.createSubscriptionClient = createSubscriptionClient;
|
|
5
|
+
const client_1 = require("./client");
|
|
6
|
+
Object.defineProperty(exports, "SubscriptionClient", { enumerable: true, get: function () { return client_1.SubscriptionClient; } });
|
|
7
|
+
var message_1 = require("./message");
|
|
8
|
+
Object.defineProperty(exports, "subscribeMessage", { enumerable: true, get: function () { return message_1.subscribeMessage; } });
|
|
9
|
+
Object.defineProperty(exports, "unsubscribeMessage", { enumerable: true, get: function () { return message_1.unsubscribeMessage; } });
|
|
10
|
+
Object.defineProperty(exports, "listSubscriptionsMessage", { enumerable: true, get: function () { return message_1.listSubscriptionsMessage; } });
|
|
11
|
+
var types_1 = require("./types");
|
|
12
|
+
Object.defineProperty(exports, "NetworkError", { enumerable: true, get: function () { return types_1.NetworkError; } });
|
|
13
|
+
function createSubscriptionClient(config) {
|
|
14
|
+
return new client_1.SubscriptionClient(config);
|
|
15
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Builds the signed message string for subscribe operations */
|
|
2
|
+
export declare function subscribeMessage(notificationType: string, channel: string, timestampSeconds: number): string;
|
|
3
|
+
/** Builds the signed message string for unsubscribe operations */
|
|
4
|
+
export declare function unsubscribeMessage(notificationType: string, channel: string, timestampSeconds: number): string;
|
|
5
|
+
/** Builds the signed message string for listing subscriptions */
|
|
6
|
+
export declare function listSubscriptionsMessage(pubkey: string, timestampSeconds: number): string;
|
package/dist/message.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.subscribeMessage = subscribeMessage;
|
|
4
|
+
exports.unsubscribeMessage = unsubscribeMessage;
|
|
5
|
+
exports.listSubscriptionsMessage = listSubscriptionsMessage;
|
|
6
|
+
/** Builds the signed message string for subscribe operations */
|
|
7
|
+
function subscribeMessage(notificationType, channel, timestampSeconds) {
|
|
8
|
+
return `Subscribe ${notificationType} ${channel} ${timestampSeconds}`;
|
|
9
|
+
}
|
|
10
|
+
/** Builds the signed message string for unsubscribe operations */
|
|
11
|
+
function unsubscribeMessage(notificationType, channel, timestampSeconds) {
|
|
12
|
+
return `Unsubscribe ${notificationType} ${channel} ${timestampSeconds}`;
|
|
13
|
+
}
|
|
14
|
+
/** Builds the signed message string for listing subscriptions */
|
|
15
|
+
function listSubscriptionsMessage(pubkey, timestampSeconds) {
|
|
16
|
+
return `ListSubscriptions ${pubkey} ${timestampSeconds}`;
|
|
17
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"root":["../client.ts","../index.ts","../message.ts","../types.ts"],"version":"5.8.3"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** Minimal logger interface — compatible with pino, console, etc. */
|
|
2
|
+
export interface Logger {
|
|
3
|
+
debug(msg: string): void;
|
|
4
|
+
}
|
|
5
|
+
/** Configuration for the subscription client */
|
|
6
|
+
export interface SubscriptionClientConfig {
|
|
7
|
+
base_url: string;
|
|
8
|
+
timeout_ms?: number;
|
|
9
|
+
logger?: Logger;
|
|
10
|
+
}
|
|
11
|
+
/** POST /subscriptions request */
|
|
12
|
+
export interface SubscribeRequest {
|
|
13
|
+
pubkey: string;
|
|
14
|
+
notification_type: string;
|
|
15
|
+
channel: string;
|
|
16
|
+
channel_address: string;
|
|
17
|
+
signature: string;
|
|
18
|
+
message: string;
|
|
19
|
+
additional_data?: Record<string, unknown>;
|
|
20
|
+
}
|
|
21
|
+
/** DELETE /subscriptions request */
|
|
22
|
+
export interface UnsubscribeRequest {
|
|
23
|
+
pubkey: string;
|
|
24
|
+
notification_type: string;
|
|
25
|
+
channel: string;
|
|
26
|
+
channel_address?: string;
|
|
27
|
+
signature: string;
|
|
28
|
+
message: string;
|
|
29
|
+
additional_data?: Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
/** GET /subscriptions auth */
|
|
32
|
+
export interface ListSubscriptionsAuth {
|
|
33
|
+
signature: string;
|
|
34
|
+
message: string;
|
|
35
|
+
}
|
|
36
|
+
/** GET /subscriptions query */
|
|
37
|
+
export interface ListSubscriptionsQuery {
|
|
38
|
+
pubkey: string;
|
|
39
|
+
notification_type?: string;
|
|
40
|
+
}
|
|
41
|
+
/** POST /subscriptions response */
|
|
42
|
+
export interface SubscribeResponse {
|
|
43
|
+
user_id: string;
|
|
44
|
+
notification_type: string;
|
|
45
|
+
channel: string;
|
|
46
|
+
channel_address: string;
|
|
47
|
+
created_at: string;
|
|
48
|
+
deep_link?: string;
|
|
49
|
+
telegram_status?: 'already_activated' | 'bot_not_configured';
|
|
50
|
+
}
|
|
51
|
+
/** DELETE /subscriptions response */
|
|
52
|
+
export interface UnsubscribeResponse {
|
|
53
|
+
deleted: boolean;
|
|
54
|
+
}
|
|
55
|
+
/** GET /subscriptions response item */
|
|
56
|
+
export interface Subscription {
|
|
57
|
+
user_id: string;
|
|
58
|
+
notification_type: string;
|
|
59
|
+
channel: string;
|
|
60
|
+
channel_address: string;
|
|
61
|
+
created_at: string;
|
|
62
|
+
}
|
|
63
|
+
export declare class NetworkError extends Error {
|
|
64
|
+
readonly status?: number | undefined;
|
|
65
|
+
readonly response?: unknown | undefined;
|
|
66
|
+
constructor(message: string, status?: number | undefined, response?: unknown | undefined);
|
|
67
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.NetworkError = void 0;
|
|
4
|
+
class NetworkError extends Error {
|
|
5
|
+
constructor(message, status, response) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.response = response;
|
|
9
|
+
this.name = 'NetworkError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
exports.NetworkError = NetworkError;
|
package/index.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { SubscriptionClient } from './client'
|
|
2
|
+
|
|
3
|
+
import type { SubscriptionClientConfig } from './types'
|
|
4
|
+
|
|
5
|
+
export { SubscriptionClient }
|
|
6
|
+
export {
|
|
7
|
+
subscribeMessage,
|
|
8
|
+
unsubscribeMessage,
|
|
9
|
+
listSubscriptionsMessage,
|
|
10
|
+
} from './message'
|
|
11
|
+
export { NetworkError } from './types'
|
|
12
|
+
export type {
|
|
13
|
+
Logger,
|
|
14
|
+
SubscriptionClientConfig,
|
|
15
|
+
SubscribeRequest,
|
|
16
|
+
SubscribeResponse,
|
|
17
|
+
UnsubscribeRequest,
|
|
18
|
+
UnsubscribeResponse,
|
|
19
|
+
ListSubscriptionsQuery,
|
|
20
|
+
ListSubscriptionsAuth,
|
|
21
|
+
Subscription,
|
|
22
|
+
} from './types'
|
|
23
|
+
|
|
24
|
+
export function createSubscriptionClient(
|
|
25
|
+
config: SubscriptionClientConfig,
|
|
26
|
+
): SubscriptionClient {
|
|
27
|
+
return new SubscriptionClient(config)
|
|
28
|
+
}
|
package/jest.config.js
ADDED
package/message.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Builds the signed message string for subscribe operations */
|
|
2
|
+
export function subscribeMessage(
|
|
3
|
+
notificationType: string,
|
|
4
|
+
channel: string,
|
|
5
|
+
timestampSeconds: number,
|
|
6
|
+
): string {
|
|
7
|
+
return `Subscribe ${notificationType} ${channel} ${timestampSeconds}`
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Builds the signed message string for unsubscribe operations */
|
|
11
|
+
export function unsubscribeMessage(
|
|
12
|
+
notificationType: string,
|
|
13
|
+
channel: string,
|
|
14
|
+
timestampSeconds: number,
|
|
15
|
+
): string {
|
|
16
|
+
return `Unsubscribe ${notificationType} ${channel} ${timestampSeconds}`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Builds the signed message string for listing subscriptions */
|
|
20
|
+
export function listSubscriptionsMessage(
|
|
21
|
+
pubkey: string,
|
|
22
|
+
timestampSeconds: number,
|
|
23
|
+
): string {
|
|
24
|
+
return `ListSubscriptions ${pubkey} ${timestampSeconds}`
|
|
25
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@marinade.finance/ts-subscription-client",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"@types/jest": "^29.5.14",
|
|
12
|
+
"jest": "^29.7.0",
|
|
13
|
+
"ts-jest": "^29.2.5",
|
|
14
|
+
"typescript": "^5.8.0"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=20.0.0"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc --build",
|
|
21
|
+
"test": "jest",
|
|
22
|
+
"clean": "rm -rf dist .tsbuildinfo"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "commonjs",
|
|
5
|
+
"lib": ["ES2020"],
|
|
6
|
+
"declaration": true,
|
|
7
|
+
"outDir": "./dist",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"moduleResolution": "node",
|
|
13
|
+
"resolveJsonModule": true
|
|
14
|
+
},
|
|
15
|
+
"include": ["./**/*.ts"],
|
|
16
|
+
"exclude": [
|
|
17
|
+
"node_modules",
|
|
18
|
+
"dist",
|
|
19
|
+
"**/*.test.ts",
|
|
20
|
+
"**/*.e2e.ts",
|
|
21
|
+
"**/__tests__"
|
|
22
|
+
]
|
|
23
|
+
}
|
package/types.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** Minimal logger interface — compatible with pino, console, etc. */
|
|
2
|
+
export interface Logger {
|
|
3
|
+
debug(msg: string): void
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** Configuration for the subscription client */
|
|
7
|
+
export interface SubscriptionClientConfig {
|
|
8
|
+
base_url: string
|
|
9
|
+
timeout_ms?: number
|
|
10
|
+
logger?: Logger
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** POST /subscriptions request */
|
|
14
|
+
export interface SubscribeRequest {
|
|
15
|
+
pubkey: string
|
|
16
|
+
notification_type: string
|
|
17
|
+
channel: string
|
|
18
|
+
channel_address: string
|
|
19
|
+
signature: string
|
|
20
|
+
message: string
|
|
21
|
+
additional_data?: Record<string, unknown>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** DELETE /subscriptions request */
|
|
25
|
+
export interface UnsubscribeRequest {
|
|
26
|
+
pubkey: string
|
|
27
|
+
notification_type: string
|
|
28
|
+
channel: string
|
|
29
|
+
channel_address?: string
|
|
30
|
+
signature: string
|
|
31
|
+
message: string
|
|
32
|
+
additional_data?: Record<string, unknown>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** GET /subscriptions auth */
|
|
36
|
+
export interface ListSubscriptionsAuth {
|
|
37
|
+
signature: string
|
|
38
|
+
message: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** GET /subscriptions query */
|
|
42
|
+
export interface ListSubscriptionsQuery {
|
|
43
|
+
pubkey: string
|
|
44
|
+
notification_type?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** POST /subscriptions response */
|
|
48
|
+
export interface SubscribeResponse {
|
|
49
|
+
user_id: string
|
|
50
|
+
notification_type: string
|
|
51
|
+
channel: string
|
|
52
|
+
channel_address: string
|
|
53
|
+
created_at: string
|
|
54
|
+
deep_link?: string
|
|
55
|
+
telegram_status?: 'already_activated' | 'bot_not_configured'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** DELETE /subscriptions response */
|
|
59
|
+
export interface UnsubscribeResponse {
|
|
60
|
+
deleted: boolean
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** GET /subscriptions response item */
|
|
64
|
+
export interface Subscription {
|
|
65
|
+
user_id: string
|
|
66
|
+
notification_type: string
|
|
67
|
+
channel: string
|
|
68
|
+
channel_address: string
|
|
69
|
+
created_at: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class NetworkError extends Error {
|
|
73
|
+
constructor(
|
|
74
|
+
message: string,
|
|
75
|
+
public readonly status?: number,
|
|
76
|
+
public readonly response?: unknown,
|
|
77
|
+
) {
|
|
78
|
+
super(message)
|
|
79
|
+
this.name = 'NetworkError'
|
|
80
|
+
}
|
|
81
|
+
}
|