sounding 0.0.4 → 0.1.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.
@@ -0,0 +1,706 @@
1
+ const { normalizeResponse, resolveBaseUrl } = require('./create-request-client')
2
+ const { createSoundingError } = require('./create-error')
3
+ const { loadDependencyFromApp } = require('./resolve-dependency')
4
+ const { resolveAuthConfig } = require('./resolve-auth-config')
5
+
6
+ /** @typedef {import('./types').AnyRecord} AnyRecord */
7
+ /** @typedef {import('./types').SoundingActor} SoundingActor */
8
+ /** @typedef {import('./types').SoundingConfig} SoundingConfig */
9
+ /** @typedef {import('./types').SoundingResponse} SoundingResponse */
10
+ /** @typedef {import('./types').SoundingSocketClient} SoundingSocketClient */
11
+ /** @typedef {import('./types').SoundingSocketConnectOptions} SoundingSocketConnectOptions */
12
+ /** @typedef {import('./types').SoundingSocketEvent} SoundingSocketEvent */
13
+ /** @typedef {import('./types').SoundingSocketManager} SoundingSocketManager */
14
+ /** @typedef {import('./types').SoundingSocketRequestOptions} SoundingSocketRequestOptions */
15
+ /** @typedef {import('./types').SoundingSailsApp} SoundingSailsApp */
16
+ /** @typedef {import('./types').SoundingWorldEngine} SoundingWorldEngine */
17
+
18
+ const SAILS_IO_SDK_QUERY = {
19
+ __sails_io_sdk_version: '1.2.1',
20
+ __sails_io_sdk_platform: 'node',
21
+ __sails_io_sdk_language: 'javascript',
22
+ }
23
+
24
+ /**
25
+ * @param {any} value
26
+ * @returns {value is AnyRecord}
27
+ */
28
+ function isPlainObject(value) {
29
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
30
+ }
31
+
32
+ /**
33
+ * @param {any} value
34
+ * @returns {AnyRecord}
35
+ */
36
+ function toHeaderObject(value) {
37
+ if (!value) {
38
+ return {}
39
+ }
40
+
41
+ if (typeof Headers !== 'undefined' && value instanceof Headers) {
42
+ return Object.fromEntries(value.entries())
43
+ }
44
+
45
+ if (Array.isArray(value)) {
46
+ return Object.fromEntries(value)
47
+ }
48
+
49
+ return { ...value }
50
+ }
51
+
52
+ /**
53
+ * @param {{ sails?: SoundingSailsApp, getConfig?: () => SoundingConfig }} input
54
+ * @returns {SoundingConfig['sockets']}
55
+ */
56
+ function resolveSocketConfig({ sails, getConfig }) {
57
+ const soundingConfig =
58
+ (typeof getConfig === 'function' ? getConfig() : null) || sails?.config?.sounding || {}
59
+
60
+ return soundingConfig.sockets || {}
61
+ }
62
+
63
+ /**
64
+ * @param {string} appPath
65
+ * @param {{ resolveImplementation?: (moduleId: string, options?: { paths?: string[] }) => string }} [options]
66
+ * @returns {any}
67
+ */
68
+ function defaultLoadSocketIoClient(appPath, options = {}) {
69
+ return loadDependencyFromApp({
70
+ appPath,
71
+ moduleId: 'socket.io-client',
72
+ purpose: 'run websocket trials',
73
+ install: 'npm install -D socket.io-client',
74
+ resolveImplementation: options.resolveImplementation,
75
+ })
76
+ }
77
+
78
+ /**
79
+ * @param {SoundingSailsApp | undefined} sails
80
+ * @returns {boolean}
81
+ */
82
+ function hasSailsSocketSupport(sails) {
83
+ return Boolean(sails?.hooks?.sockets && sails?.io && sails?.sockets)
84
+ }
85
+
86
+ /**
87
+ * @param {{ appPath?: string }} input
88
+ * @returns {Error}
89
+ */
90
+ function createSocketHookUnavailableError({ appPath }) {
91
+ return createSoundingError({
92
+ code: 'E_SOUNDING_SOCKET_HOOK_UNAVAILABLE',
93
+ name: 'SoundingSocketError',
94
+ message:
95
+ 'Sounding websocket helpers need Sails socket support. Install and enable `sails-hook-sockets`, then lift the app with the sockets hook enabled.',
96
+ details: {
97
+ dependency: 'sails-hook-sockets',
98
+ install: 'npm install sails-hook-sockets',
99
+ appPath,
100
+ suggestion:
101
+ 'If your test config disables `hooks.sockets`, set it to `true` for websocket trials.',
102
+ },
103
+ })
104
+ }
105
+
106
+ /**
107
+ * @param {{ code: string, event?: string, timeout?: number, cause?: unknown, message: string }} input
108
+ * @returns {Error}
109
+ */
110
+ function createSocketError({ code, event, timeout, cause, message }) {
111
+ return createSoundingError({
112
+ code,
113
+ name: 'SoundingSocketError',
114
+ message,
115
+ details: {
116
+ event,
117
+ timeout,
118
+ },
119
+ cause,
120
+ })
121
+ }
122
+
123
+ /**
124
+ * @param {any} value
125
+ * @returns {any}
126
+ */
127
+ function cloneJsonish(value) {
128
+ if (Array.isArray(value)) {
129
+ return value.map(cloneJsonish)
130
+ }
131
+
132
+ if (isPlainObject(value)) {
133
+ return Object.fromEntries(
134
+ Object.entries(value).map(([key, nested]) => [key, cloneJsonish(nested)])
135
+ )
136
+ }
137
+
138
+ return value
139
+ }
140
+
141
+ /**
142
+ * @param {any[]} args
143
+ * @returns {any}
144
+ */
145
+ function normalizeEventPayload(args) {
146
+ if (args.length === 0) {
147
+ return undefined
148
+ }
149
+
150
+ if (args.length === 1) {
151
+ return args[0]
152
+ }
153
+
154
+ return args
155
+ }
156
+
157
+ /**
158
+ * @param {any} jwr
159
+ * @param {string} method
160
+ * @param {string} target
161
+ * @param {any} body
162
+ * @returns {SoundingResponse}
163
+ */
164
+ function normalizeJwrResponse(jwr, method, target, body) {
165
+ const status = jwr?.statusCode === undefined ? 200 : jwr.statusCode
166
+ const headers = jwr?.headers || {}
167
+ const responseBody = jwr?.body === undefined ? body : jwr.body
168
+
169
+ return normalizeResponse({
170
+ raw: jwr,
171
+ status,
172
+ statusText: '',
173
+ headers,
174
+ url: target,
175
+ redirected: status >= 300 && status < 400,
176
+ responseBody,
177
+ request: {
178
+ method: method.toUpperCase(),
179
+ target,
180
+ transport: 'socket',
181
+ url: target,
182
+ },
183
+ })
184
+ }
185
+
186
+ /**
187
+ * @param {SoundingSailsApp} sails
188
+ * @param {AnyRecord} session
189
+ * @returns {Promise<string | null>}
190
+ */
191
+ async function createSessionCookie(sails, session) {
192
+ if (
193
+ !sails?.session ||
194
+ typeof sails.session.generateNewSidCookie !== 'function' ||
195
+ typeof sails.session.parseSessionIdFromCookie !== 'function' ||
196
+ typeof sails.session.set !== 'function'
197
+ ) {
198
+ return null
199
+ }
200
+
201
+ const cookie = sails.session.generateNewSidCookie()
202
+ const sid = sails.session.parseSessionIdFromCookie(cookie)
203
+
204
+ await new Promise((resolve, reject) => {
205
+ sails.session.set(
206
+ sid,
207
+ {
208
+ cookie: {
209
+ httpOnly: true,
210
+ path: '/',
211
+ },
212
+ ...session,
213
+ },
214
+ (error) => {
215
+ if (error) {
216
+ reject(error)
217
+ return
218
+ }
219
+
220
+ resolve()
221
+ }
222
+ )
223
+ })
224
+
225
+ return cookie
226
+ }
227
+
228
+ /**
229
+ * @param {any} socket
230
+ * @param {number} timeout
231
+ * @returns {Promise<void>}
232
+ */
233
+ function waitForSocketConnect(socket, timeout) {
234
+ if (socket.connected) {
235
+ return Promise.resolve()
236
+ }
237
+
238
+ return new Promise((resolve, reject) => {
239
+ let timer = null
240
+
241
+ function cleanup() {
242
+ clearTimeout(timer)
243
+ socket.off?.('connect', onConnect)
244
+ socket.off?.('connect_error', onError)
245
+ socket.off?.('error', onError)
246
+ socket.off?.('connect_timeout', onTimeout)
247
+ }
248
+
249
+ function onConnect() {
250
+ cleanup()
251
+ resolve()
252
+ }
253
+
254
+ function onTimeout() {
255
+ cleanup()
256
+ reject(
257
+ createSocketError({
258
+ code: 'E_SOUNDING_SOCKET_CONNECT_TIMEOUT',
259
+ timeout,
260
+ message: `Sounding socket did not connect within ${timeout}ms.`,
261
+ })
262
+ )
263
+ }
264
+
265
+ function onError(error) {
266
+ cleanup()
267
+ reject(
268
+ createSocketError({
269
+ code: 'E_SOUNDING_SOCKET_CONNECT_FAILED',
270
+ cause: error,
271
+ message: 'Sounding socket failed to connect.',
272
+ })
273
+ )
274
+ }
275
+
276
+ timer = setTimeout(onTimeout, timeout)
277
+ socket.on?.('connect', onConnect)
278
+ socket.on?.('connect_error', onError)
279
+ socket.on?.('error', onError)
280
+ socket.on?.('connect_timeout', onTimeout)
281
+ })
282
+ }
283
+
284
+ /**
285
+ * @param {any} socket
286
+ * @param {{
287
+ * timeout: number,
288
+ * defaultHeaders?: AnyRecord,
289
+ * }} options
290
+ * @returns {SoundingSocketClient}
291
+ */
292
+ function wrapSocket(socket, { timeout, defaultHeaders = {} }) {
293
+ /** @type {SoundingSocketEvent[]} */
294
+ const history = []
295
+ /** @type {SoundingSocketEvent[]} */
296
+ const buffer = []
297
+ /** @type {Array<{ event: string, resolve: (payload: any) => void, reject: (error: Error) => void, timer: NodeJS.Timeout }>} */
298
+ const waiters = []
299
+ let closed = false
300
+
301
+ /**
302
+ * @param {string} event
303
+ * @param {any[]} args
304
+ */
305
+ function recordEvent(event, args) {
306
+ const entry = {
307
+ event,
308
+ data: normalizeEventPayload(args),
309
+ args: args.map(cloneJsonish),
310
+ receivedAt: new Date().toISOString(),
311
+ }
312
+
313
+ history.push(entry)
314
+
315
+ const waiterIndex = waiters.findIndex((waiter) => waiter.event === event)
316
+ if (waiterIndex === -1) {
317
+ buffer.push(entry)
318
+ return
319
+ }
320
+
321
+ const [waiter] = waiters.splice(waiterIndex, 1)
322
+ clearTimeout(waiter.timer)
323
+ waiter.resolve(entry.data)
324
+ }
325
+
326
+ function attachEventBuffer() {
327
+ if (typeof socket.onAny === 'function') {
328
+ socket.onAny((event, ...args) => {
329
+ recordEvent(event, args)
330
+ })
331
+ return
332
+ }
333
+
334
+ const originalOnevent = socket.onevent
335
+ if (typeof originalOnevent === 'function') {
336
+ socket.onevent = function soundingOnevent(packet) {
337
+ const args = packet?.data || []
338
+ if (typeof args[0] === 'string') {
339
+ recordEvent(args[0], args.slice(1))
340
+ }
341
+
342
+ return originalOnevent.call(this, packet)
343
+ }
344
+ }
345
+ }
346
+
347
+ attachEventBuffer()
348
+
349
+ /**
350
+ * @param {string} method
351
+ * @param {string} target
352
+ * @param {any} [payload]
353
+ * @param {SoundingSocketRequestOptions} [options]
354
+ * @returns {Promise<SoundingResponse>}
355
+ */
356
+ function send(method, target, payload, options = {}) {
357
+ return new Promise((resolve, reject) => {
358
+ const requestTimeout = options.timeout || timeout
359
+ const timer = setTimeout(() => {
360
+ reject(
361
+ createSocketError({
362
+ code: 'E_SOUNDING_SOCKET_REQUEST_TIMEOUT',
363
+ timeout: requestTimeout,
364
+ message: `Sounding socket request to ${target} did not complete within ${requestTimeout}ms.`,
365
+ })
366
+ )
367
+ }, requestTimeout)
368
+
369
+ const requestOptions = {
370
+ method,
371
+ url: target,
372
+ headers: {
373
+ ...defaultHeaders,
374
+ ...toHeaderObject(options.headers),
375
+ },
376
+ }
377
+
378
+ if (payload !== undefined) {
379
+ requestOptions.params = payload
380
+ }
381
+
382
+ try {
383
+ socket.emit(method, requestOptions, (jwr) => {
384
+ clearTimeout(timer)
385
+ const body = jwr?.body
386
+ resolve(normalizeJwrResponse(jwr, method, target, body))
387
+ })
388
+ } catch (error) {
389
+ clearTimeout(timer)
390
+ reject(error)
391
+ }
392
+ })
393
+ }
394
+
395
+ /** @type {SoundingSocketClient} */
396
+ const client = {
397
+ get id() {
398
+ return socket.id
399
+ },
400
+
401
+ get connected() {
402
+ return Boolean(socket.connected)
403
+ },
404
+
405
+ on(event, listener) {
406
+ socket.on(event, listener)
407
+ return client
408
+ },
409
+
410
+ off(event, listener) {
411
+ socket.off(event, listener)
412
+ return client
413
+ },
414
+
415
+ events(event) {
416
+ return history
417
+ .filter((entry) => (event ? entry.event === event : true))
418
+ .map((entry) => entry.data)
419
+ },
420
+
421
+ async receive(event, options = {}) {
422
+ const bufferedIndex = buffer.findIndex((entry) => entry.event === event)
423
+ if (bufferedIndex !== -1) {
424
+ const [entry] = buffer.splice(bufferedIndex, 1)
425
+ return entry.data
426
+ }
427
+
428
+ const eventTimeout = options.timeout || timeout
429
+
430
+ return new Promise((resolve, reject) => {
431
+ const timer = setTimeout(() => {
432
+ const waiterIndex = waiters.findIndex(
433
+ (waiter) => waiter.event === event && waiter.resolve === resolve
434
+ )
435
+
436
+ if (waiterIndex !== -1) {
437
+ waiters.splice(waiterIndex, 1)
438
+ }
439
+
440
+ reject(
441
+ createSocketError({
442
+ code: 'E_SOUNDING_SOCKET_EVENT_TIMEOUT',
443
+ event,
444
+ timeout: eventTimeout,
445
+ message: `Sounding socket did not receive \`${event}\` within ${eventTimeout}ms.`,
446
+ })
447
+ )
448
+ }, eventTimeout)
449
+
450
+ waiters.push({
451
+ event,
452
+ resolve,
453
+ reject,
454
+ timer,
455
+ })
456
+ })
457
+ },
458
+
459
+ async request(method, target, payloadOrOptions, maybeOptions) {
460
+ const normalizedMethod = method.toLowerCase()
461
+ const hasPayload = !['get', 'head'].includes(normalizedMethod)
462
+ const payload = hasPayload ? payloadOrOptions : undefined
463
+ const options = (hasPayload ? maybeOptions : payloadOrOptions) || {}
464
+
465
+ return send(normalizedMethod, target, payload, options)
466
+ },
467
+
468
+ async get(target, options) {
469
+ return send('get', target, undefined, options)
470
+ },
471
+
472
+ async head(target, options) {
473
+ return send('head', target, undefined, options)
474
+ },
475
+
476
+ async post(target, payload, options) {
477
+ return send('post', target, payload, options)
478
+ },
479
+
480
+ async put(target, payload, options) {
481
+ return send('put', target, payload, options)
482
+ },
483
+
484
+ async patch(target, payload, options) {
485
+ return send('patch', target, payload, options)
486
+ },
487
+
488
+ async delete(target, payload, options) {
489
+ return send('delete', target, payload, options)
490
+ },
491
+
492
+ async close() {
493
+ if (closed) {
494
+ return
495
+ }
496
+
497
+ closed = true
498
+ for (const waiter of waiters.splice(0)) {
499
+ clearTimeout(waiter.timer)
500
+ waiter.reject(
501
+ createSocketError({
502
+ code: 'E_SOUNDING_SOCKET_CLOSED',
503
+ event: waiter.event,
504
+ message: 'Sounding socket closed before the event was received.',
505
+ })
506
+ )
507
+ }
508
+
509
+ socket.disconnect()
510
+ },
511
+ }
512
+
513
+ return client
514
+ }
515
+
516
+ /**
517
+ * @param {{
518
+ * sails?: SoundingSailsApp,
519
+ * getConfig?: () => SoundingConfig,
520
+ * actor?: SoundingActor | null,
521
+ * options?: SoundingSocketConnectOptions,
522
+ * }} input
523
+ * @returns {Promise<{ headers: AnyRecord, initialConnectionHeaders: AnyRecord }>}
524
+ */
525
+ async function resolveActorSocketOptions({ sails, getConfig, actor, options = {} }) {
526
+ const actorHeaders = toHeaderObject(actor?.sounding?.headers || actor?.headers)
527
+ const optionHeaders = toHeaderObject(options.headers)
528
+ const optionInitialHeaders = toHeaderObject(options.initialConnectionHeaders)
529
+ const actorSession =
530
+ actor?.sounding?.session ||
531
+ actor?.session ||
532
+ (actor?.id
533
+ ? {
534
+ [resolveAuthConfig({ sails, getConfig }).sessionKey]: actor.id,
535
+ ...(actor.team ? { teamId: actor.team } : {}),
536
+ ...(actor.teamId ? { teamId: actor.teamId } : {}),
537
+ }
538
+ : {})
539
+
540
+ if (!optionInitialHeaders.cookie && Object.keys(actorSession).length > 0) {
541
+ const cookie = await createSessionCookie(sails, actorSession)
542
+ if (cookie) {
543
+ optionInitialHeaders.cookie = cookie
544
+ }
545
+ }
546
+
547
+ return {
548
+ headers: {
549
+ ...actorHeaders,
550
+ ...optionHeaders,
551
+ },
552
+ initialConnectionHeaders: {
553
+ ...optionInitialHeaders,
554
+ },
555
+ }
556
+ }
557
+
558
+ /**
559
+ * @param {{
560
+ * actor?: SoundingActor | string | null,
561
+ * world?: SoundingWorldEngine,
562
+ * sails?: SoundingSailsApp,
563
+ * getConfig?: () => SoundingConfig,
564
+ * }} input
565
+ * @returns {SoundingActor | null}
566
+ */
567
+ function resolveActor({ actor, world, sails, getConfig }) {
568
+ if (!actor) {
569
+ return null
570
+ }
571
+
572
+ if (typeof actor === 'object') {
573
+ return actor
574
+ }
575
+
576
+ const auth = resolveAuthConfig({ sails, getConfig })
577
+ return (
578
+ world?.current?.[auth.worldCollection]?.[actor] ||
579
+ world?.current?.[actor] ||
580
+ null
581
+ )
582
+ }
583
+
584
+ /**
585
+ * @param {{
586
+ * sails?: SoundingSailsApp,
587
+ * getConfig?: () => SoundingConfig,
588
+ * world?: SoundingWorldEngine,
589
+ * appPathResolver?: () => string,
590
+ * loadSocketIoClient?: (appPath: string) => any,
591
+ * }} input
592
+ * @returns {SoundingSocketManager}
593
+ */
594
+ function createSocketManager({
595
+ sails,
596
+ getConfig,
597
+ world,
598
+ appPathResolver = () => sails?.config?.appPath || process.cwd(),
599
+ loadSocketIoClient = defaultLoadSocketIoClient,
600
+ } = {}) {
601
+ /** @type {Set<SoundingSocketClient>} */
602
+ const activeSockets = new Set()
603
+
604
+ /**
605
+ * @param {SoundingActor | string | null} actor
606
+ * @param {SoundingSocketConnectOptions} [options]
607
+ * @returns {Promise<SoundingSocketClient>}
608
+ */
609
+ async function connectAs(actor, options = {}) {
610
+ const config = resolveSocketConfig({ sails, getConfig })
611
+
612
+ if (config.enabled === false) {
613
+ throw createSocketError({
614
+ code: 'E_SOUNDING_SOCKET_DISABLED',
615
+ message: 'Sounding socket helpers are disabled by `sounding.sockets.enabled`.',
616
+ })
617
+ }
618
+
619
+ const timeout = options.timeout || config.timeout || 1000
620
+ const appPath = appPathResolver()
621
+ const socketClient = await loadSocketIoClient(appPath)
622
+
623
+ if (!hasSailsSocketSupport(sails)) {
624
+ throw createSocketHookUnavailableError({ appPath })
625
+ }
626
+
627
+ const baseUrl = resolveBaseUrl({
628
+ sails,
629
+ getConfig,
630
+ override: options.baseUrl || config.baseUrl,
631
+ })
632
+ const resolvedActor = resolveActor({ actor, world, sails, getConfig })
633
+ const actorOptions = await resolveActorSocketOptions({
634
+ sails,
635
+ getConfig,
636
+ actor: resolvedActor,
637
+ options,
638
+ })
639
+ const connectionOptions = {
640
+ transports: options.transports || config.transports || ['websocket'],
641
+ path: options.path || config.path || '/socket.io',
642
+ timeout,
643
+ reconnection: false,
644
+ headers: {
645
+ ...toHeaderObject(config.headers),
646
+ ...actorOptions.headers,
647
+ },
648
+ initialConnectionHeaders: {
649
+ ...toHeaderObject(config.initialConnectionHeaders),
650
+ ...actorOptions.initialConnectionHeaders,
651
+ },
652
+ }
653
+
654
+ const rawSocket = socketClient.io(baseUrl, {
655
+ transports: connectionOptions.transports,
656
+ path: connectionOptions.path,
657
+ timeout,
658
+ reconnection: false,
659
+ query: SAILS_IO_SDK_QUERY,
660
+ extraHeaders: connectionOptions.initialConnectionHeaders,
661
+ transportOptions: Object.fromEntries(
662
+ connectionOptions.transports.map((transport) => [
663
+ transport,
664
+ {
665
+ extraHeaders: connectionOptions.initialConnectionHeaders,
666
+ },
667
+ ])
668
+ ),
669
+ })
670
+ await waitForSocketConnect(rawSocket, timeout)
671
+
672
+ const socket = wrapSocket(rawSocket, {
673
+ timeout,
674
+ defaultHeaders: connectionOptions.headers,
675
+ })
676
+ activeSockets.add(socket)
677
+ return socket
678
+ }
679
+
680
+ return {
681
+ connect(options) {
682
+ return connectAs(null, options)
683
+ },
684
+
685
+ as(actor) {
686
+ return {
687
+ connect(options) {
688
+ return connectAs(actor, options)
689
+ },
690
+ }
691
+ },
692
+
693
+ async closeAll() {
694
+ const sockets = Array.from(activeSockets)
695
+ activeSockets.clear()
696
+ await Promise.all(sockets.map((socket) => socket.close()))
697
+ },
698
+ }
699
+ }
700
+
701
+ module.exports = {
702
+ createSocketHookUnavailableError,
703
+ createSocketManager,
704
+ defaultLoadSocketIoClient,
705
+ hasSailsSocketSupport,
706
+ }