@zimic/interceptor 0.17.0-canary.1 → 0.17.0-canary.3

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 (58) hide show
  1. package/dist/{chunk-3SKHNQLL.mjs → chunk-L75WKVZO.mjs} +508 -60
  2. package/dist/chunk-L75WKVZO.mjs.map +1 -0
  3. package/dist/{chunk-TYHJPU5G.js → chunk-PURXNE6R.js} +519 -61
  4. package/dist/chunk-PURXNE6R.js.map +1 -0
  5. package/dist/cli.js +141 -17
  6. package/dist/cli.js.map +1 -1
  7. package/dist/cli.mjs +137 -13
  8. package/dist/cli.mjs.map +1 -1
  9. package/dist/http.d.ts +14 -0
  10. package/dist/http.js +449 -269
  11. package/dist/http.js.map +1 -1
  12. package/dist/http.mjs +449 -269
  13. package/dist/http.mjs.map +1 -1
  14. package/dist/scripts/postinstall.js +6 -6
  15. package/dist/scripts/postinstall.js.map +1 -1
  16. package/dist/scripts/postinstall.mjs +5 -5
  17. package/dist/scripts/postinstall.mjs.map +1 -1
  18. package/dist/server.d.ts +16 -0
  19. package/dist/server.js +6 -6
  20. package/dist/server.mjs +1 -1
  21. package/package.json +11 -10
  22. package/src/cli/browser/init.ts +5 -6
  23. package/src/cli/cli.ts +140 -55
  24. package/src/cli/server/start.ts +22 -7
  25. package/src/cli/server/token/create.ts +33 -0
  26. package/src/cli/server/token/list.ts +23 -0
  27. package/src/cli/server/token/remove.ts +22 -0
  28. package/src/http/interceptor/HttpInterceptorClient.ts +49 -27
  29. package/src/http/interceptor/HttpInterceptorStore.ts +25 -9
  30. package/src/http/interceptor/LocalHttpInterceptor.ts +6 -3
  31. package/src/http/interceptor/RemoteHttpInterceptor.ts +9 -4
  32. package/src/http/interceptor/types/options.ts +15 -0
  33. package/src/http/interceptorWorker/HttpInterceptorWorker.ts +14 -16
  34. package/src/http/interceptorWorker/RemoteHttpInterceptorWorker.ts +17 -12
  35. package/src/http/interceptorWorker/types/options.ts +1 -0
  36. package/src/http/requestHandler/errors/TimesCheckError.ts +1 -1
  37. package/src/server/InterceptorServer.ts +52 -8
  38. package/src/server/constants.ts +1 -1
  39. package/src/server/errors/InvalidInterceptorTokenError.ts +13 -0
  40. package/src/server/errors/InvalidInterceptorTokenFileError.ts +13 -0
  41. package/src/server/errors/InvalidInterceptorTokenValueError.ts +13 -0
  42. package/src/server/types/options.ts +9 -0
  43. package/src/server/types/public.ts +9 -0
  44. package/src/server/types/schema.ts +4 -4
  45. package/src/server/utils/auth.ts +301 -0
  46. package/src/server/utils/fetch.ts +3 -1
  47. package/src/utils/data.ts +13 -0
  48. package/src/utils/files.ts +14 -0
  49. package/src/utils/{console.ts → logging.ts} +5 -7
  50. package/src/utils/webSocket.ts +57 -11
  51. package/src/webSocket/WebSocketClient.ts +11 -5
  52. package/src/webSocket/WebSocketHandler.ts +72 -51
  53. package/src/webSocket/WebSocketServer.ts +25 -4
  54. package/src/webSocket/constants.ts +2 -0
  55. package/src/webSocket/errors/UnauthorizedWebSocketConnectionError.ts +11 -0
  56. package/src/webSocket/types.ts +49 -52
  57. package/dist/chunk-3SKHNQLL.mjs.map +0 -1
  58. package/dist/chunk-TYHJPU5G.js.map +0 -1
@@ -29,6 +29,15 @@ export interface InterceptorServer {
29
29
  */
30
30
  logUnhandledRequests: boolean;
31
31
 
32
+ /**
33
+ * The directory where the authorized interceptor authentication tokens are saved. If provided, only remote
34
+ * interceptors bearing a valid token will be accepted. This option is essential if you are exposing your interceptor
35
+ * server publicly. For local development and testing, though, `--tokens-dir` is optional.
36
+ *
37
+ * @default undefined
38
+ */
39
+ tokensDirectory?: string;
40
+
32
41
  /**
33
42
  * Whether the server is running.
34
43
  *
@@ -1,7 +1,7 @@
1
1
  import { HttpMethod } from '@zimic/http';
2
2
 
3
3
  import { SerializedHttpRequest, SerializedResponse } from '@/utils/fetch';
4
- import { WebSocket } from '@/webSocket/types';
4
+ import { WebSocketSchema } from '@/webSocket/types';
5
5
 
6
6
  export interface HttpHandlerCommit {
7
7
  id: string;
@@ -9,13 +9,13 @@ export interface HttpHandlerCommit {
9
9
  method: HttpMethod;
10
10
  }
11
11
 
12
- export type InterceptorServerWebSocketSchema = WebSocket.ServiceSchema<{
13
- 'interceptors/workers/use/commit': {
12
+ export type InterceptorServerWebSocketSchema = WebSocketSchema<{
13
+ 'interceptors/workers/commit': {
14
14
  event: HttpHandlerCommit;
15
15
  reply: {};
16
16
  };
17
17
 
18
- 'interceptors/workers/use/reset': {
18
+ 'interceptors/workers/reset': {
19
19
  event?: HttpHandlerCommit[];
20
20
  reply: {};
21
21
  };
@@ -0,0 +1,301 @@
1
+ import crypto from 'crypto';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import color from 'picocolors';
6
+ import util from 'util';
7
+ import { z } from 'zod';
8
+
9
+ import {
10
+ BASE64URL_REGEX,
11
+ convertHexLengthToBase64urlLength,
12
+ convertHexLengthToByteLength,
13
+ HEX_REGEX,
14
+ } from '@/utils/data';
15
+ import { pathExists } from '@/utils/files';
16
+ import { logger } from '@/utils/logging';
17
+
18
+ import InvalidInterceptorTokenError from '../errors/InvalidInterceptorTokenError';
19
+ import InvalidInterceptorTokenFileError from '../errors/InvalidInterceptorTokenFileError';
20
+ import InvalidInterceptorTokenValueError from '../errors/InvalidInterceptorTokenValueError';
21
+
22
+ export const DEFAULT_INTERCEPTOR_TOKENS_DIRECTORY = path.join(
23
+ '.zimic',
24
+ 'interceptor',
25
+ 'server',
26
+ `tokens${process.env.VITEST_POOL_ID}`,
27
+ );
28
+
29
+ export const INTERCEPTOR_TOKEN_ID_HEX_LENGTH = 32;
30
+
31
+ export const INTERCEPTOR_TOKEN_SECRET_HEX_LENGTH = 64;
32
+ export const INTERCEPTOR_TOKEN_VALUE_HEX_LENGTH = INTERCEPTOR_TOKEN_ID_HEX_LENGTH + INTERCEPTOR_TOKEN_SECRET_HEX_LENGTH;
33
+ export const INTERCEPTOR_TOKEN_VALUE_BASE64URL_LENGTH = convertHexLengthToBase64urlLength(
34
+ INTERCEPTOR_TOKEN_VALUE_HEX_LENGTH,
35
+ );
36
+
37
+ export const INTERCEPTOR_TOKEN_SALT_HEX_LENGTH = 64;
38
+ export const INTERCEPTOR_TOKEN_HASH_ITERATIONS = Number(process.env.INTERCEPTOR_TOKEN_HASH_ITERATIONS);
39
+ export const INTERCEPTOR_TOKEN_HASH_HEX_LENGTH = 128;
40
+ export const INTERCEPTOR_TOKEN_HASH_ALGORITHM = 'sha512';
41
+
42
+ const pbkdf2 = util.promisify(crypto.pbkdf2);
43
+
44
+ async function hashInterceptorToken(plainToken: string, salt: string) {
45
+ const hashBuffer = await pbkdf2(
46
+ plainToken,
47
+ salt,
48
+ INTERCEPTOR_TOKEN_HASH_ITERATIONS,
49
+ convertHexLengthToByteLength(INTERCEPTOR_TOKEN_HASH_HEX_LENGTH),
50
+ INTERCEPTOR_TOKEN_HASH_ALGORITHM,
51
+ );
52
+
53
+ const hash = hashBuffer.toString('hex');
54
+ return hash;
55
+ }
56
+
57
+ interface InterceptorTokenSecret {
58
+ hash: string;
59
+ salt: string;
60
+ value: string;
61
+ }
62
+
63
+ export interface InterceptorToken {
64
+ id: string;
65
+ name?: string;
66
+ secret: InterceptorTokenSecret;
67
+ value: string;
68
+ createdAt: Date;
69
+ }
70
+
71
+ export function createInterceptorTokenId() {
72
+ return crypto.randomUUID().replace(/[^a-z0-9]/g, '');
73
+ }
74
+
75
+ export function isValidInterceptorTokenId(tokenId: string) {
76
+ return tokenId.length === INTERCEPTOR_TOKEN_ID_HEX_LENGTH && HEX_REGEX.test(tokenId);
77
+ }
78
+
79
+ function isValidInterceptorTokenValue(tokenValue: string) {
80
+ return tokenValue.length === INTERCEPTOR_TOKEN_VALUE_BASE64URL_LENGTH && BASE64URL_REGEX.test(tokenValue);
81
+ }
82
+
83
+ export async function createInterceptorTokensDirectory(tokensDirectory: string) {
84
+ try {
85
+ const parentTokensDirectory = path.dirname(tokensDirectory);
86
+ await fs.promises.mkdir(parentTokensDirectory, { recursive: true });
87
+ await fs.promises.mkdir(tokensDirectory, { mode: 0o700, recursive: true });
88
+ await fs.promises.appendFile(path.join(tokensDirectory, '.gitignore'), `*${os.EOL}`, { encoding: 'utf-8' });
89
+ } catch (error) {
90
+ logger.error(
91
+ `${color.red(color.bold('✖'))} Failed to create the tokens directory: ${color.magenta(tokensDirectory)}`,
92
+ );
93
+ throw error;
94
+ }
95
+ }
96
+
97
+ const interceptorTokenFileContentSchema = z.object({
98
+ version: z.literal(1),
99
+ token: z.object({
100
+ id: z.string().length(INTERCEPTOR_TOKEN_ID_HEX_LENGTH).regex(HEX_REGEX),
101
+ name: z.string().optional(),
102
+ secret: z.object({
103
+ hash: z.string().length(INTERCEPTOR_TOKEN_HASH_HEX_LENGTH).regex(HEX_REGEX),
104
+ salt: z.string().length(INTERCEPTOR_TOKEN_SALT_HEX_LENGTH).regex(HEX_REGEX),
105
+ }),
106
+ createdAt: z
107
+ .string()
108
+ .datetime()
109
+ .transform((value) => new Date(value)),
110
+ }),
111
+ });
112
+
113
+ export type InterceptorTokenFileContent = z.infer<typeof interceptorTokenFileContentSchema>;
114
+
115
+ export namespace InterceptorTokenFileContent {
116
+ export type Input = z.input<typeof interceptorTokenFileContentSchema>;
117
+ }
118
+
119
+ export type PersistedInterceptorToken = InterceptorTokenFileContent['token'];
120
+
121
+ namespace PersistedInterceptorToken {
122
+ export type Input = InterceptorTokenFileContent.Input['token'];
123
+ }
124
+
125
+ export async function saveInterceptorTokenToFile(tokensDirectory: string, token: InterceptorToken) {
126
+ const tokeFilePath = path.join(tokensDirectory, token.id);
127
+
128
+ const persistedToken: PersistedInterceptorToken.Input = {
129
+ id: token.id,
130
+ name: token.name,
131
+ secret: {
132
+ hash: token.secret.hash,
133
+ salt: token.secret.salt,
134
+ },
135
+ createdAt: token.createdAt.toISOString(),
136
+ };
137
+
138
+ const tokenFileContent = interceptorTokenFileContentSchema.parse({
139
+ version: 1,
140
+ token: persistedToken,
141
+ } satisfies InterceptorTokenFileContent.Input);
142
+
143
+ await fs.promises.writeFile(tokeFilePath, JSON.stringify(tokenFileContent, null, 2), {
144
+ mode: 0o600,
145
+ encoding: 'utf-8',
146
+ });
147
+
148
+ return tokeFilePath;
149
+ }
150
+
151
+ export async function readInterceptorTokenFromFile(
152
+ tokenId: InterceptorToken['id'],
153
+ options: { tokensDirectory: string },
154
+ ): Promise<PersistedInterceptorToken | null> {
155
+ if (!isValidInterceptorTokenId(tokenId)) {
156
+ throw new InvalidInterceptorTokenError(tokenId);
157
+ }
158
+
159
+ const tokenFilePath = path.join(options.tokensDirectory, tokenId);
160
+ const tokenFileExists = await pathExists(tokenFilePath);
161
+
162
+ if (!tokenFileExists) {
163
+ return null;
164
+ }
165
+
166
+ const tokenFileContentAsString = await fs.promises.readFile(tokenFilePath, { encoding: 'utf-8' });
167
+
168
+ const validation = interceptorTokenFileContentSchema.safeParse(JSON.parse(tokenFileContentAsString) as unknown);
169
+
170
+ if (!validation.success) {
171
+ throw new InvalidInterceptorTokenFileError(tokenFilePath, validation.error.message);
172
+ }
173
+
174
+ return validation.data.token;
175
+ }
176
+
177
+ export async function createInterceptorToken(options: {
178
+ name?: string;
179
+ tokensDirectory: string;
180
+ }): Promise<InterceptorToken> {
181
+ const { name, tokensDirectory } = options;
182
+
183
+ const tokensDirectoryExists = await pathExists(tokensDirectory);
184
+
185
+ if (!tokensDirectoryExists) {
186
+ await createInterceptorTokensDirectory(tokensDirectory);
187
+ }
188
+
189
+ const tokenId = createInterceptorTokenId();
190
+
191
+ /* istanbul ignore if -- @preserve
192
+ * This should never happen, but let's check that the token identifier is valid after generated. */
193
+ if (!isValidInterceptorTokenId(tokenId)) {
194
+ throw new InvalidInterceptorTokenError(tokenId);
195
+ }
196
+
197
+ const tokenSecretSizeInBytes = convertHexLengthToByteLength(INTERCEPTOR_TOKEN_SECRET_HEX_LENGTH);
198
+ const tokenSecret = crypto.randomBytes(tokenSecretSizeInBytes).toString('hex');
199
+
200
+ const tokenSecretSaltSizeInBytes = convertHexLengthToByteLength(INTERCEPTOR_TOKEN_SALT_HEX_LENGTH);
201
+ const tokenSecretSalt = crypto.randomBytes(tokenSecretSaltSizeInBytes).toString('hex');
202
+ const tokenSecretHash = await hashInterceptorToken(tokenSecret, tokenSecretSalt);
203
+
204
+ const tokenValue = Buffer.from(`${tokenId}${tokenSecret}`, 'hex').toString('base64url');
205
+
206
+ /* istanbul ignore if -- @preserve
207
+ * This should never happen, but let's check that the token value is valid after generated. */
208
+ if (!isValidInterceptorTokenValue(tokenValue)) {
209
+ throw new InvalidInterceptorTokenValueError(tokenValue);
210
+ }
211
+
212
+ const token: InterceptorToken = {
213
+ id: tokenId,
214
+ name,
215
+ secret: {
216
+ hash: tokenSecretHash,
217
+ salt: tokenSecretSalt,
218
+ value: tokenSecret,
219
+ },
220
+ value: tokenValue,
221
+ createdAt: new Date(),
222
+ };
223
+
224
+ await saveInterceptorTokenToFile(tokensDirectory, token);
225
+
226
+ return token;
227
+ }
228
+
229
+ export async function listInterceptorTokens(options: { tokensDirectory: string }) {
230
+ const tokensDirectoryExists = await pathExists(options.tokensDirectory);
231
+
232
+ if (!tokensDirectoryExists) {
233
+ return [];
234
+ }
235
+
236
+ const files = await fs.promises.readdir(options.tokensDirectory);
237
+
238
+ const tokenReadPromises = files.map(async (file) => {
239
+ if (!isValidInterceptorTokenId(file)) {
240
+ return null;
241
+ }
242
+
243
+ const tokenId = file;
244
+ const token = await readInterceptorTokenFromFile(tokenId, options);
245
+ return token;
246
+ });
247
+
248
+ const tokenCandidates = await Promise.allSettled(tokenReadPromises);
249
+
250
+ const tokens: PersistedInterceptorToken[] = [];
251
+
252
+ for (const tokenCandidate of tokenCandidates) {
253
+ if (tokenCandidate.status === 'rejected') {
254
+ console.error(tokenCandidate.reason);
255
+ } else if (tokenCandidate.value !== null) {
256
+ tokens.push(tokenCandidate.value);
257
+ }
258
+ }
259
+
260
+ tokens.sort((token, otherToken) => token.createdAt.getTime() - otherToken.createdAt.getTime());
261
+
262
+ return tokens;
263
+ }
264
+
265
+ export async function validateInterceptorToken(tokenValue: string, options: { tokensDirectory: string }) {
266
+ if (!isValidInterceptorTokenValue(tokenValue)) {
267
+ throw new InvalidInterceptorTokenValueError(tokenValue);
268
+ }
269
+
270
+ const decodedTokenValue = Buffer.from(tokenValue, 'base64url').toString('hex');
271
+
272
+ const tokenId = decodedTokenValue.slice(0, INTERCEPTOR_TOKEN_ID_HEX_LENGTH);
273
+ const tokenSecret = decodedTokenValue.slice(
274
+ INTERCEPTOR_TOKEN_ID_HEX_LENGTH,
275
+ INTERCEPTOR_TOKEN_ID_HEX_LENGTH + INTERCEPTOR_TOKEN_VALUE_HEX_LENGTH,
276
+ );
277
+
278
+ const tokenFromFile = await readInterceptorTokenFromFile(tokenId, options);
279
+
280
+ if (!tokenFromFile) {
281
+ throw new InvalidInterceptorTokenValueError(tokenValue);
282
+ }
283
+
284
+ const tokenSecretHash = await hashInterceptorToken(tokenSecret, tokenFromFile.secret.salt);
285
+
286
+ if (tokenSecretHash !== tokenFromFile.secret.hash) {
287
+ throw new InvalidInterceptorTokenValueError(tokenValue);
288
+ }
289
+ }
290
+
291
+ export async function removeInterceptorToken(tokenId: string, options: { tokensDirectory: string }) {
292
+ /* istanbul ignore if -- @preserve
293
+ * At this point, we should have a valid tokenId. This is just a sanity check. */
294
+ if (!isValidInterceptorTokenId(tokenId)) {
295
+ throw new InvalidInterceptorTokenError(tokenId);
296
+ }
297
+
298
+ const tokenFilePath = path.join(options.tokensDirectory, tokenId);
299
+
300
+ await fs.promises.rm(tokenFilePath, { force: true });
301
+ }
@@ -3,6 +3,8 @@ import { FetchAPI } from '@whatwg-node/server';
3
3
  import { importFile } from '@/utils/files';
4
4
 
5
5
  export async function getFetchAPI(): Promise<FetchAPI> {
6
+ const File = await importFile();
7
+
6
8
  return {
7
9
  fetch,
8
10
  Request,
@@ -17,7 +19,7 @@ export async function getFetchAPI(): Promise<FetchAPI> {
17
19
  TextDecoderStream,
18
20
  TextEncoderStream,
19
21
  Blob,
20
- File: await importFile(),
22
+ File,
21
23
  crypto: globalThis.crypto,
22
24
  btoa,
23
25
  TextEncoder,
package/src/utils/data.ts CHANGED
@@ -26,3 +26,16 @@ export function convertBase64ToArrayBuffer(base64Value: string) {
26
26
  return Buffer.from(base64Value, 'base64');
27
27
  }
28
28
  }
29
+
30
+ export const HEX_REGEX = /^[a-z0-9]+$/;
31
+
32
+ export function convertHexLengthToByteLength(hexLength: number) {
33
+ return Math.ceil(hexLength / 2); // 1 byte = 2 hex characters
34
+ }
35
+
36
+ export const BASE64URL_REGEX = /^[a-zA-Z0-9-_]+$/;
37
+
38
+ export function convertHexLengthToBase64urlLength(hexLength: number) {
39
+ const byteLength = convertHexLengthToByteLength(hexLength);
40
+ return Math.ceil((byteLength * 4) / 3); // 1 byte = 4/3 base64url characters
41
+ }
@@ -1,4 +1,5 @@
1
1
  import createCachedDynamicImport from '@zimic/utils/import/createCachedDynamicImport';
2
+ import type fs from 'fs';
2
3
 
3
4
  export const importFile = createCachedDynamicImport(
4
5
  /* istanbul ignore next -- @preserve
@@ -11,3 +12,16 @@ export function isGlobalFileAvailable() {
11
12
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
12
13
  return globalThis.File !== undefined;
13
14
  }
15
+
16
+ export const importFilesystem = createCachedDynamicImport<typeof fs>(() => import('fs'));
17
+
18
+ export async function pathExists(path: string) {
19
+ const fs = await importFilesystem();
20
+
21
+ try {
22
+ await fs.promises.access(path);
23
+ return true;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
@@ -1,10 +1,15 @@
1
1
  import { HttpFormData, HttpHeaders, HttpSearchParams } from '@zimic/http';
2
2
  import createCachedDynamicImport from '@zimic/utils/import/createCachedDynamicImport';
3
+ import Logger from '@zimic/utils/logging/Logger';
3
4
  import color from 'picocolors';
4
5
 
5
6
  import { isClientSide } from './environment';
6
7
  import { isGlobalFileAvailable } from './files';
7
8
 
9
+ export const logger = new Logger({
10
+ prefix: color.cyan('[@zimic/interceptor]'),
11
+ });
12
+
8
13
  function stringifyJSONToLog(value: unknown): string {
9
14
  return JSON.stringify(
10
15
  value,
@@ -79,10 +84,3 @@ export async function formatValueToLog(value: unknown, options: { colors?: boole
79
84
  sorted: true,
80
85
  });
81
86
  }
82
-
83
- export function logWithPrefix(messageOrMessages: unknown, options: { method?: 'log' | 'warn' | 'error' } = {}) {
84
- const { method = 'log' } = options;
85
-
86
- const messages = Array.isArray(messageOrMessages) ? messageOrMessages : [messageOrMessages];
87
- console[method](color.cyan('[@zimic/interceptor]'), ...messages);
88
- }
@@ -1,5 +1,8 @@
1
1
  import ClientSocket, { type WebSocketServer as ServerSocket } from 'isomorphic-ws';
2
2
 
3
+ import { WebSocketControlMessage } from '@/webSocket/constants';
4
+ import UnauthorizedWebSocketConnectionError from '@/webSocket/errors/UnauthorizedWebSocketConnectionError';
5
+
3
6
  class WebSocketTimeoutError extends Error {}
4
7
 
5
8
  export class WebSocketOpenTimeoutError extends WebSocketTimeoutError {
@@ -37,9 +40,10 @@ export async function waitForOpenClientSocket(
37
40
  socket: ClientSocket,
38
41
  options: {
39
42
  timeout?: number;
43
+ waitForAuthentication?: boolean;
40
44
  } = {},
41
45
  ) {
42
- const { timeout: timeoutDuration = DEFAULT_WEB_SOCKET_LIFECYCLE_TIMEOUT } = options;
46
+ const { timeout: timeoutDuration = DEFAULT_WEB_SOCKET_LIFECYCLE_TIMEOUT, waitForAuthentication = false } = options;
43
47
 
44
48
  const isAlreadyOpen = socket.readyState === socket.OPEN;
45
49
 
@@ -48,24 +52,60 @@ export async function waitForOpenClientSocket(
48
52
  }
49
53
 
50
54
  await new Promise<void>((resolve, reject) => {
51
- function handleOpenError(error: unknown) {
55
+ function removeAllSocketListeners() {
56
+ socket.removeEventListener('message', handleSocketMessage); // eslint-disable-line @typescript-eslint/no-use-before-define
52
57
  socket.removeEventListener('open', handleOpenSuccess); // eslint-disable-line @typescript-eslint/no-use-before-define
58
+ socket.removeEventListener('error', handleOpenError); // eslint-disable-line @typescript-eslint/no-use-before-define
59
+ socket.removeEventListener('close', handleClose); // eslint-disable-line @typescript-eslint/no-use-before-define
60
+ }
61
+
62
+ function handleOpenError(error: unknown) {
63
+ removeAllSocketListeners();
53
64
  reject(error);
54
65
  }
55
66
 
67
+ function handleClose(event: ClientSocket.CloseEvent) {
68
+ const isUnauthorized = event.code === 1008;
69
+
70
+ /* istanbul ignore else -- @preserve
71
+ * An unauthorized close event is the only one we expect to happen here. */
72
+ if (isUnauthorized) {
73
+ const unauthorizedError = new UnauthorizedWebSocketConnectionError(event);
74
+ handleOpenError(unauthorizedError);
75
+ } else {
76
+ handleOpenError(event);
77
+ }
78
+ }
79
+
56
80
  const openTimeout = setTimeout(() => {
57
81
  const timeoutError = new WebSocketOpenTimeoutError(timeoutDuration);
58
82
  handleOpenError(timeoutError);
59
83
  }, timeoutDuration);
60
84
 
61
85
  function handleOpenSuccess() {
62
- socket.removeEventListener('error', handleOpenError);
86
+ removeAllSocketListeners();
63
87
  clearTimeout(openTimeout);
64
88
  resolve();
65
89
  }
66
90
 
67
- socket.addEventListener('open', handleOpenSuccess);
91
+ function handleSocketMessage(message: ClientSocket.MessageEvent) {
92
+ const hasValidAuth = message.data === ('socket:auth:valid' satisfies WebSocketControlMessage);
93
+
94
+ /* istanbul ignore else -- @preserve
95
+ * We currently only support the 'socket:auth:valid' message and it is the only possible control message here. */
96
+ if (hasValidAuth) {
97
+ handleOpenSuccess();
98
+ }
99
+ }
100
+
101
+ if (waitForAuthentication) {
102
+ socket.addEventListener('message', handleSocketMessage);
103
+ } else {
104
+ socket.addEventListener('open', handleOpenSuccess);
105
+ }
106
+
68
107
  socket.addEventListener('error', handleOpenError);
108
+ socket.addEventListener('close', handleClose);
69
109
  });
70
110
  }
71
111
 
@@ -78,24 +118,30 @@ export async function closeClientSocket(socket: ClientSocket, options: { timeout
78
118
  }
79
119
 
80
120
  await new Promise<void>((resolve, reject) => {
81
- function handleCloseError(error: unknown) {
82
- socket.removeEventListener('close', handleCloseSuccess); // eslint-disable-line @typescript-eslint/no-use-before-define
121
+ function removeAllSocketListeners() {
122
+ socket.removeEventListener('error', handleError); // eslint-disable-line @typescript-eslint/no-use-before-define
123
+ socket.removeEventListener('close', handleClose); // eslint-disable-line @typescript-eslint/no-use-before-define
124
+ }
125
+
126
+ function handleError(error: unknown) {
127
+ removeAllSocketListeners();
83
128
  reject(error);
84
129
  }
85
130
 
86
131
  const closeTimeout = setTimeout(() => {
87
132
  const timeoutError = new WebSocketCloseTimeoutError(timeoutDuration);
88
- handleCloseError(timeoutError);
133
+ handleError(timeoutError);
89
134
  }, timeoutDuration);
90
135
 
91
- function handleCloseSuccess() {
92
- socket.removeEventListener('error', handleCloseError);
136
+ function handleClose() {
137
+ removeAllSocketListeners();
93
138
  clearTimeout(closeTimeout);
94
139
  resolve();
95
140
  }
96
141
 
97
- socket.addEventListener('error', handleCloseError);
98
- socket.addEventListener('close', handleCloseSuccess);
142
+ socket.addEventListener('error', handleError);
143
+ socket.addEventListener('close', handleClose);
144
+
99
145
  socket.close();
100
146
  });
101
147
  }
@@ -1,7 +1,7 @@
1
1
  import validateURLProtocol from '@zimic/utils/url/validateURLProtocol';
2
2
  import ClientSocket from 'isomorphic-ws';
3
3
 
4
- import { WebSocket } from './types';
4
+ import { WebSocketSchema } from './types';
5
5
  import WebSocketHandler from './WebSocketHandler';
6
6
 
7
7
  const SUPPORTED_WEB_SOCKET_PROTOCOLS = ['ws', 'wss'];
@@ -12,7 +12,7 @@ interface WebSocketClientOptions {
12
12
  messageTimeout?: number;
13
13
  }
14
14
 
15
- class WebSocketClient<Schema extends WebSocket.ServiceSchema> extends WebSocketHandler<Schema> {
15
+ class WebSocketClient<Schema extends WebSocketSchema> extends WebSocketHandler<Schema> {
16
16
  private url: URL;
17
17
 
18
18
  private socket?: ClientSocket;
@@ -31,11 +31,17 @@ class WebSocketClient<Schema extends WebSocket.ServiceSchema> extends WebSocketH
31
31
  return this.socket !== undefined && this.socket.readyState === this.socket.OPEN;
32
32
  }
33
33
 
34
- async start() {
35
- this.socket = new ClientSocket(this.url);
34
+ async start(options: { parameters?: Record<string, string>; waitForAuthentication?: boolean } = {}) {
35
+ const parametersAsString = options.parameters
36
+ ? Object.entries(options.parameters)
37
+ .map(([key, value]) => `${key}=${value}`)
38
+ .map(encodeURIComponent)
39
+ : [];
40
+
41
+ this.socket = new ClientSocket(this.url, parametersAsString);
36
42
 
37
43
  try {
38
- await super.registerSocket(this.socket);
44
+ await super.registerSocket(this.socket, options);
39
45
  } catch (error) {
40
46
  await this.stop();
41
47
  throw error;