Skip to content

Commit e8d6cca

Browse files
committed
core: define and handle ws errors
1 parent f3fc4fc commit e8d6cca

File tree

8 files changed

+170
-65
lines changed

8 files changed

+170
-65
lines changed

packages/core/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
"dependencies": {
7373
"axios": "^1.6.8",
7474
"isomorphic-ws": "^5.0.0",
75+
"isows": "^1.0.6",
7576
"json-bigint": "^1.0.0",
7677
"tiny-invariant": "^1.3.3",
7778
"zustand": "^4.5.2"

packages/core/src/errors/base.ts

+15-34
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,46 @@
11
import { getVersion } from '../utils/getVersion.js'
22

3-
import type { Evaluate, OneOf } from '../types/utils.js'
4-
53
export type ErrorType<name extends string = 'Error'> = Error & { name: name }
64

7-
type BaseErrorOptions = Evaluate<
8-
OneOf<{ details?: string | undefined } | { cause: BaseError | Error }> & {
9-
docsPath?: string | undefined
10-
docsSlug?: string | undefined
11-
metaMessages?: string[] | undefined
12-
}
13-
>
5+
type BaseErrorParameters = {
6+
cause?: BaseError | Error | undefined
7+
details?: string | undefined
8+
metaMessages?: string[] | undefined
9+
name?: string | undefined
10+
}
1411

1512
export type BaseErrorType = BaseError & { name: 'RenegadeCoreError' }
1613
export class BaseError extends Error {
1714
details: string
18-
docsPath?: string | undefined
1915
metaMessages?: string[] | undefined
2016
shortMessage: string
2117

2218
override name = 'RenegadeCoreError'
23-
get docsBaseUrl() {
24-
return 'todo: put a docs link here'
25-
}
2619
get version() {
2720
return getVersion()
2821
}
2922

30-
constructor(shortMessage: string, options: BaseErrorOptions = {}) {
23+
constructor(shortMessage: string, args: BaseErrorParameters = {}) {
3124
super()
3225

3326
const details =
34-
options.cause instanceof BaseError
35-
? options.cause.details
36-
: options.cause?.message
37-
? options.cause.message
38-
: options.details!
39-
const docsPath =
40-
options.cause instanceof BaseError
41-
? options.cause.docsPath || options.docsPath
42-
: options.docsPath
27+
args.cause instanceof BaseError
28+
? args.cause.details
29+
: args.cause?.message
30+
? args.cause.message
31+
: args.details!
4332

4433
this.message = [
4534
shortMessage || 'An error occurred.',
4635
'',
47-
...(options.metaMessages ? [...options.metaMessages, ''] : []),
48-
...(docsPath
49-
? [
50-
`Docs: ${this.docsBaseUrl}${docsPath}.html${
51-
options.docsSlug ? `#${options.docsSlug}` : ''
52-
}`,
53-
]
54-
: []),
36+
...(args.metaMessages ? [...args.metaMessages, ''] : []),
5537
...(details ? [`Details: ${details}`] : []),
5638
`Version: ${this.version}`,
5739
].join('\n')
5840

59-
if (options.cause) this.cause = options.cause
41+
if (args.cause) this.cause = args.cause
6042
this.details = details
61-
this.docsPath = docsPath
62-
this.metaMessages = options.metaMessages
43+
this.metaMessages = args.metaMessages
6344
this.shortMessage = shortMessage
6445
}
6546

packages/core/src/errors/websocket.ts

+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { BaseError } from './base.js'
2+
3+
export type SocketClosedErrorType = SocketClosedError & {
4+
name: 'SocketClosedError'
5+
}
6+
export class SocketClosedError extends BaseError {
7+
constructor({
8+
url,
9+
}: {
10+
url?: string | undefined
11+
} = {}) {
12+
super('The socket has been closed.', {
13+
metaMessages: [url && `URL: ${url}`].filter(Boolean) as string[],
14+
name: 'SocketClosedError',
15+
})
16+
}
17+
}
18+
19+
export type WebSocketRequestErrorType = WebSocketRequestError & {
20+
name: 'WebSocketRequestError'
21+
}
22+
export class WebSocketRequestError extends BaseError {
23+
constructor({
24+
body,
25+
cause,
26+
details,
27+
url,
28+
}: {
29+
body?: { [key: string]: unknown } | undefined
30+
cause?: Error | undefined
31+
details?: string | undefined
32+
url: string
33+
}) {
34+
super('WebSocket request failed.', {
35+
cause,
36+
details,
37+
metaMessages: [
38+
`URL: ${url}`,
39+
body && `Request body: ${JSON.stringify(body)}`,
40+
].filter(Boolean) as string[],
41+
name: 'WebSocketRequestError',
42+
})
43+
}
44+
}
45+
46+
export type WebSocketConnectionErrorType = WebSocketConnectionError & {
47+
name: 'WebSocketConnectionError'
48+
}
49+
50+
export class WebSocketConnectionError extends BaseError {
51+
constructor({
52+
url,
53+
cause,
54+
details,
55+
}: {
56+
url: string
57+
cause?: Error | undefined
58+
details?: string | undefined
59+
}) {
60+
super('Failed to establish WebSocket connection.', {
61+
cause,
62+
details,
63+
metaMessages: [`URL: ${url}`],
64+
name: 'WebSocketConnectionError',
65+
})
66+
}
67+
}

packages/core/src/utils/websocket.ts

+71-22
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { SIG_EXPIRATION_BUFFER_MS } from '../constants.js'
22
import type { RenegadeConfig } from '../createConfig.js'
3+
import {
4+
SocketClosedError,
5+
WebSocketConnectionError,
6+
WebSocketRequestError,
7+
} from '../errors/websocket.js'
38
import { addExpiringAuthToHeaders } from './http.js'
49

510
export enum AuthType {
@@ -48,6 +53,24 @@ export class RelayerWebsocket {
4853

4954
private ws: WebSocket | null = null
5055

56+
private handleOpen = (event: Event) => {
57+
if (!this.ws) return
58+
const message = this.buildSubscriptionMessage()
59+
this.request(message)
60+
61+
return this.onopenCallback?.call(this.ws, event)
62+
}
63+
64+
private handleClose = (event: CloseEvent) => {
65+
this.cleanup()
66+
return this.oncloseCallback?.call(this.ws!, event)
67+
}
68+
69+
private handleError = async (event: Event) => {
70+
this.cleanup()
71+
return this.onerrorCallback?.call(this.ws!, event)
72+
}
73+
5174
constructor(params: RelayerWebsocketParams) {
5275
this.config = params.config
5376
this.topic = params.topic
@@ -62,33 +85,35 @@ export class RelayerWebsocket {
6285
// | Public API |
6386
// --------------
6487

65-
public connect(): void {
88+
public async connect(): Promise<void> {
6689
if (this.ws) {
6790
throw new Error(
6891
'WebSocket connection attempt aborted: already connected.',
6992
)
7093
}
7194

72-
const instance = this
73-
instance.ws = new WebSocket(this.config.getWebsocketBaseUrl())
74-
75-
instance.ws.onopen = function (this: WebSocket, event: Event) {
76-
const message = instance.buildSubscriptionMessage()
77-
this.send(JSON.stringify(message))
78-
79-
return instance.onopenCallback?.call(this, event)
80-
}
81-
82-
instance.ws.onmessage = instance.onmessage
83-
84-
instance.ws.onclose = function (this: WebSocket, event: CloseEvent) {
85-
instance.cleanup()
86-
return instance.oncloseCallback?.call(this, event)
87-
}
88-
89-
instance.ws.onerror = function (this: WebSocket, event: Event) {
90-
instance.cleanup()
91-
return instance.onerrorCallback?.call(this, event)
95+
const WebSocket = await import('isows').then((module) => module.WebSocket)
96+
const url = this.config.getWebsocketBaseUrl()
97+
this.ws = new WebSocket(url)
98+
99+
this.ws.addEventListener('open', this.handleOpen)
100+
this.ws.addEventListener('message', this.onmessage)
101+
this.ws.addEventListener('close', this.handleClose)
102+
this.ws.addEventListener('error', this.handleError)
103+
104+
// Wait for the socket to open.
105+
if (this.ws?.readyState === WebSocket.CONNECTING) {
106+
await new Promise((resolve, reject) => {
107+
if (!this.ws) return
108+
this.ws.onopen = (event) => resolve(event)
109+
this.ws.onerror = (error) =>
110+
reject(
111+
new WebSocketConnectionError({
112+
url,
113+
cause: error as unknown as Error,
114+
}),
115+
)
116+
})
92117
}
93118
}
94119

@@ -98,7 +123,7 @@ export class RelayerWebsocket {
98123
}
99124

100125
const message = this.buildUnsubscriptionMessage()
101-
this.ws.send(JSON.stringify(message))
126+
this.request(message)
102127

103128
this.ws.close()
104129
}
@@ -107,6 +132,21 @@ export class RelayerWebsocket {
107132
// | Private API |
108133
// ---------------
109134

135+
private request(message: SubscriptionMessage | UnsubscriptionMessage): void {
136+
if (
137+
this.ws?.readyState === this.ws?.CLOSED ||
138+
this.ws?.readyState === this.ws?.CLOSING
139+
) {
140+
throw new WebSocketRequestError({
141+
body: message,
142+
url: this.ws?.url || '',
143+
cause: new SocketClosedError({ url: this.ws?.url }),
144+
})
145+
}
146+
147+
this.ws?.send(JSON.stringify(message))
148+
}
149+
110150
private buildSubscriptionMessage(): SubscriptionMessage {
111151
const body = {
112152
method: 'subscribe' as const,
@@ -147,6 +187,15 @@ export class RelayerWebsocket {
147187
}
148188

149189
private cleanup(): void {
190+
// Remove all event listeners before nullifying the reference
191+
if (this.ws) {
192+
this.ws.removeEventListener('open', this.handleOpen)
193+
this.ws.removeEventListener('message', this.onmessage)
194+
this.ws.removeEventListener('close', this.handleClose)
195+
this.ws.removeEventListener('error', this.handleError)
196+
}
197+
198+
// Nullify the WebSocket instance
150199
this.ws = null
151200
}
152201
}

packages/core/src/utils/websocketWaiter.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -66,16 +66,16 @@ export async function websocketWaiter<T>(
6666
reject(new Error('Websocket connection closed'))
6767
}
6868
},
69-
onerrorCallback: (event: Event) => {
69+
onerrorCallback: (error: Event | Error) => {
7070
if (!promiseSettled) {
7171
promiseSettled = true
72-
reject(event)
72+
reject(error)
7373
}
7474
},
7575
}
7676

7777
const ws = new RelayerWebsocket(wsParams)
78-
ws.connect()
78+
ws.connect().catch((error) => reject(error))
7979

8080
if (params.timeout) {
8181
setTimeout(() => {

packages/react/src/errors/base.ts

-3
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,6 @@ import { getVersion } from '../utils/getVersion.js'
55
export type BaseErrorType = BaseError & { name: 'RenegadeError' }
66
export class BaseError extends CoreError {
77
override name = 'RenegadeError'
8-
override get docsBaseUrl() {
9-
return 'todo: put a docs link here'
10-
}
118
override get version() {
129
return getVersion()
1310
}

packages/react/src/errors/context.ts

+1-3
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ export type RenegadeProviderNotFoundErrorType =
77
export class RenegadeProviderNotFoundError extends BaseError {
88
override name = 'RenegadeProviderNotFoundError'
99
constructor() {
10-
super('`useConfig` must be used within `RenegadeProvider`.', {
11-
docsPath: '/api/RenegadeProvider',
12-
})
10+
super('`useConfig` must be used within `RenegadeProvider`.')
1311
}
1412
}

pnpm-lock.yaml

+12
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)