|
1 | | -import { consola } from 'consola' |
| 1 | +import consola from 'consola' |
2 | 2 |
|
3 | | -const webSocketWrapper = (url: string, signal?: AbortSignal) => { |
| 3 | +export type WebSocketCheckResult = |
| 4 | + | { ok: true } |
| 5 | + | { ok: false; reason: 'timeout' | 'cancelled' | 'error'; message: string } |
| 6 | + |
| 7 | +export interface CheckWebSocketOptions { |
| 8 | + timeout?: number; |
| 9 | + signal?: AbortSignal; |
| 10 | + protocols?: string | string[]; |
| 11 | +} |
| 12 | + |
| 13 | +const webSocketWrapper = (url: string, options: CheckWebSocketOptions = {}): Promise<WebSocketCheckResult> => { |
4 | 14 | const debug = (message: string, ...args: unknown[]) => consola.debug(`[webSocketWrapper] ${url} ${message}`, ...args) |
5 | 15 |
|
6 | | - return new Promise((resolve, reject) => { |
7 | | - debug('opening...') |
| 16 | + const { timeout, signal, protocols } = options |
| 17 | + |
| 18 | + const combinedSignal = AbortSignal.any([ |
| 19 | + ...(timeout !== undefined ? [AbortSignal.timeout(timeout)] : []), |
| 20 | + ...(signal !== undefined ? [signal] : []), |
| 21 | + ]) |
| 22 | + |
| 23 | + const getAbortResult = (): WebSocketCheckResult => { |
| 24 | + if ( |
| 25 | + timeout !== undefined && |
| 26 | + combinedSignal.reason instanceof DOMException && |
| 27 | + combinedSignal.reason.name === 'TimeoutError' |
| 28 | + ) { |
| 29 | + debug('timed out') |
| 30 | + |
| 31 | + return { |
| 32 | + ok: false, |
| 33 | + reason: 'timeout', |
| 34 | + message: combinedSignal.reason.message |
| 35 | + } |
| 36 | + } |
8 | 37 |
|
9 | | - const dispose = () => { |
10 | | - signal?.removeEventListener('abort', abortHandler) |
| 38 | + debug('aborted') |
11 | 39 |
|
12 | | - connection.close() |
| 40 | + return { |
| 41 | + ok: false, |
| 42 | + reason: 'cancelled', |
| 43 | + message: combinedSignal.reason instanceof Error |
| 44 | + ? combinedSignal.reason.message |
| 45 | + : 'Check was cancelled via AbortSignal.', |
13 | 46 | } |
| 47 | + } |
| 48 | + |
| 49 | + if (combinedSignal?.aborted) { |
| 50 | + return Promise.resolve(getAbortResult()) |
| 51 | + } |
14 | 52 |
|
15 | | - const abortHandler = () => { |
16 | | - debug('aborted') |
| 53 | + return new Promise<WebSocketCheckResult>((resolve) => { |
| 54 | + let settled = false |
17 | 55 |
|
18 | | - dispose() |
| 56 | + const settle = (result: WebSocketCheckResult) => { |
| 57 | + if (settled) return |
19 | 58 |
|
20 | | - reject(new Error('AbortError')) |
| 59 | + settled = true |
| 60 | + |
| 61 | + combinedSignal?.removeEventListener('abort', onAbort) |
| 62 | + |
| 63 | + if ( |
| 64 | + ws.readyState === WebSocket.CONNECTING || |
| 65 | + ws.readyState === WebSocket.OPEN |
| 66 | + ) { |
| 67 | + // Use code 1000 (Normal Closure) so servers don't log warnings. |
| 68 | + try { |
| 69 | + ws.close(1000, 'probe complete') |
| 70 | + } catch { |
| 71 | + /* ignore */ |
| 72 | + } |
| 73 | + } |
| 74 | + |
| 75 | + resolve(result) |
21 | 76 | } |
22 | 77 |
|
23 | | - signal?.addEventListener('abort', abortHandler) |
| 78 | + let ws: WebSocket |
24 | 79 |
|
25 | | - const connection = new WebSocket(url) |
| 80 | + try { |
| 81 | + debug('opening...') |
26 | 82 |
|
27 | | - connection.onopen = (event) => { |
28 | | - debug('opened', event) |
| 83 | + ws = new WebSocket(url, protocols) |
| 84 | + } catch (err) { |
| 85 | + // `new WebSocket()` throws synchronously for an invalid URL scheme. |
| 86 | + resolve({ |
| 87 | + ok: false, |
| 88 | + reason: 'error', |
| 89 | + message: err instanceof Error ? err.message : String(err), |
| 90 | + }) |
29 | 91 |
|
30 | | - dispose() |
| 92 | + return |
| 93 | + } |
| 94 | + |
| 95 | + ws.onopen = (event) => { |
| 96 | + debug('opened', event) |
31 | 97 |
|
32 | | - resolve(null) |
| 98 | + settle({ ok: true }) |
33 | 99 | } |
34 | 100 |
|
35 | | - connection.onerror = (event) => { |
| 101 | + ws.onerror = (event) => { |
36 | 102 | debug('error', event) |
37 | 103 |
|
38 | | - dispose() |
39 | | - |
40 | | - reject(event) |
| 104 | + // The browser hides error details for security reasons; the close event |
| 105 | + // that always follows carries a (limited) code and reason. |
41 | 106 | } |
42 | 107 |
|
43 | | - connection.onclose = (event) => { |
| 108 | + ws.onclose = (event) => { |
44 | 109 | debug('closed', event) |
| 110 | + |
| 111 | + settle({ |
| 112 | + ok: false, |
| 113 | + reason: 'error', |
| 114 | + message: event.reason |
| 115 | + ? `Connection closed before opening: ${event.reason} (code ${event.code})` |
| 116 | + : `Connection closed before opening (code ${event.code}).`, |
| 117 | + }) |
| 118 | + } |
| 119 | + |
| 120 | + const onAbort = (): void => { |
| 121 | + settle(getAbortResult()) |
45 | 122 | } |
| 123 | + |
| 124 | + combinedSignal?.addEventListener('abort', onAbort, { once: true }) |
46 | 125 | }) |
47 | 126 | } |
48 | 127 |
|
|
0 commit comments