sounding 0.0.3 → 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.
@@ -1,31 +1,116 @@
1
1
  const { Transform } = require('node:stream')
2
2
  const QS = require('node:querystring')
3
3
  const { resolveAuthConfig } = require('./resolve-auth-config')
4
-
4
+ const { createSoundingError } = require('./create-error')
5
+
6
+ /** @typedef {import('./types').AnyRecord} AnyRecord */
7
+ /** @typedef {import('./types').SoundingActor} SoundingActor */
8
+ /** @typedef {import('./types').SoundingRequestClient} SoundingRequestClient */
9
+ /** @typedef {import('./types').SoundingRequestOptions} SoundingRequestOptions */
10
+ /** @typedef {import('./types').SoundingResponse} SoundingResponse */
11
+ /** @typedef {import('./types').SoundingSailsApp} SoundingSailsApp */
12
+ /** @typedef {import('./types').SoundingTransport} SoundingTransport */
13
+ /** @typedef {import('./types').SoundingWorldEngine} SoundingWorldEngine */
14
+
15
+ /**
16
+ * @param {string} value
17
+ * @returns {boolean}
18
+ */
5
19
  function isAbsoluteUrl(value) {
6
20
  return /^https?:\/\//i.test(value)
7
21
  }
8
22
 
23
+ /**
24
+ * @param {any} value
25
+ * @returns {value is AnyRecord}
26
+ */
9
27
  function isPlainObject(value) {
10
28
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
11
29
  }
12
30
 
31
+ /**
32
+ * @param {string} value
33
+ * @returns {string}
34
+ */
13
35
  function trimTrailingSlash(value) {
14
36
  return value.replace(/\/$/, '')
15
37
  }
16
38
 
17
- function looksLikeJson({ contentType, body }) {
39
+ /**
40
+ * @param {{ contentType?: string, body?: any }} input
41
+ * @returns {boolean}
42
+ */
43
+ function shouldParseJson({ contentType, body }) {
18
44
  if (!body) {
19
45
  return false
20
46
  }
21
47
 
22
- if (contentType?.includes('application/json')) {
48
+ const mediaType = contentType?.split(';')[0]?.trim().toLowerCase()
49
+
50
+ if (mediaType === 'application/json' || mediaType?.endsWith('+json')) {
23
51
  return true
24
52
  }
25
53
 
26
54
  return /^[\[{]/.test(String(body).trim())
27
55
  }
28
56
 
57
+ /**
58
+ * @param {{
59
+ * error: unknown,
60
+ * status: number,
61
+ * contentType: string,
62
+ * url?: string,
63
+ * body: string,
64
+ * }} input
65
+ * @returns {Error & { code: string, status: number, url?: string, contentType: string, body: string }}
66
+ */
67
+ function createJsonParseError({ error, status, contentType, url, body }) {
68
+ const fromUrl = url ? ` from ${url}` : ''
69
+ return createSoundingError({
70
+ code: 'E_SOUNDING_JSON_PARSE',
71
+ message: `Sounding could not parse JSON response${fromUrl} (status ${status}).`,
72
+ details: {
73
+ status,
74
+ url,
75
+ contentType,
76
+ body,
77
+ },
78
+ cause: error,
79
+ name: 'SoundingJsonParseError',
80
+ })
81
+ }
82
+
83
+ function createVirtualResponseStreamError(error) {
84
+ return createSoundingError({
85
+ code: 'E_SOUNDING_VIRTUAL_RESPONSE_STREAM',
86
+ message: 'Sounding virtual response stream failed.',
87
+ cause: error,
88
+ name: 'SoundingVirtualResponseStreamError',
89
+ })
90
+ }
91
+
92
+ /**
93
+ * @param {{ body: string, status: number, contentType: string, url?: string }} input
94
+ * @returns {any}
95
+ */
96
+ function parseJsonResponse({ body, status, contentType, url }) {
97
+ try {
98
+ return JSON.parse(body)
99
+ } catch (error) {
100
+ throw createJsonParseError({
101
+ error,
102
+ status,
103
+ contentType,
104
+ url,
105
+ body,
106
+ })
107
+ }
108
+ }
109
+
110
+ /**
111
+ * @param {any} value
112
+ * @returns {{ body: string, data: any }}
113
+ */
29
114
  function normalizeBodyValue(value) {
30
115
  if (value === undefined || value === null) {
31
116
  return {
@@ -47,6 +132,46 @@ function normalizeBodyValue(value) {
47
132
  }
48
133
  }
49
134
 
135
+ /**
136
+ * @param {HeadersInit | AnyRecord | undefined} headers
137
+ * @returns {HeadersInit | AnyRecord | undefined}
138
+ */
139
+ function normalizeRequestHeadersMetadata(headers) {
140
+ if (!headers) {
141
+ return undefined
142
+ }
143
+
144
+ if (typeof headers.forEach === 'function') {
145
+ let count = 0
146
+ headers.forEach(() => {
147
+ count += 1
148
+ })
149
+ return count > 0 ? headers : undefined
150
+ }
151
+
152
+ return Object.keys(headers).length > 0 ? headers : undefined
153
+ }
154
+
155
+ /**
156
+ * @param {{
157
+ * raw: unknown,
158
+ * status: number,
159
+ * statusText?: string,
160
+ * headers?: HeadersInit | AnyRecord,
161
+ * url?: string,
162
+ * redirected?: boolean,
163
+ * responseBody?: any,
164
+ * session?: AnyRecord,
165
+ * request?: {
166
+ * method: string,
167
+ * target: string,
168
+ * transport: SoundingTransport | 'socket',
169
+ * url?: string,
170
+ * headers?: HeadersInit | AnyRecord,
171
+ * },
172
+ * }} input
173
+ * @returns {SoundingResponse}
174
+ */
50
175
  function normalizeResponse({
51
176
  raw,
52
177
  status,
@@ -55,13 +180,20 @@ function normalizeResponse({
55
180
  url,
56
181
  redirected = false,
57
182
  responseBody,
183
+ session,
184
+ request,
58
185
  }) {
59
186
  const normalizedHeaders = new Headers(headers)
60
187
  const contentType = normalizedHeaders.get('content-type') || ''
61
188
  let { body, data } = normalizeBodyValue(responseBody)
62
189
 
63
- if (data === undefined && looksLikeJson({ contentType, body })) {
64
- data = JSON.parse(body)
190
+ if (data === undefined && shouldParseJson({ contentType, body })) {
191
+ data = parseJsonResponse({
192
+ body,
193
+ status,
194
+ contentType,
195
+ url,
196
+ })
65
197
  }
66
198
 
67
199
  return {
@@ -70,7 +202,9 @@ function normalizeResponse({
70
202
  status,
71
203
  statusText,
72
204
  url,
205
+ request,
73
206
  redirected,
207
+ session,
74
208
  headers: normalizedHeaders,
75
209
  body,
76
210
  data,
@@ -86,6 +220,10 @@ function normalizeResponse({
86
220
  }
87
221
  }
88
222
 
223
+ /**
224
+ * @param {{ sails?: SoundingSailsApp, getConfig?: () => AnyRecord }} input
225
+ * @returns {AnyRecord}
226
+ */
89
227
  function resolveRequestConfig({ sails, getConfig }) {
90
228
  const soundingConfig =
91
229
  (typeof getConfig === 'function' ? getConfig() : null) || sails?.config?.sounding || {}
@@ -93,6 +231,10 @@ function resolveRequestConfig({ sails, getConfig }) {
93
231
  return soundingConfig.request || {}
94
232
  }
95
233
 
234
+ /**
235
+ * @param {{ sails?: SoundingSailsApp, getConfig?: () => AnyRecord, override?: string }} input
236
+ * @returns {string}
237
+ */
96
238
  function resolveBaseUrl({ sails, getConfig, override }) {
97
239
  if (override) {
98
240
  return trimTrailingSlash(override)
@@ -124,11 +266,17 @@ function resolveBaseUrl({ sails, getConfig, override }) {
124
266
  return `http://127.0.0.1:${sails.config.port}`
125
267
  }
126
268
 
127
- throw new Error(
128
- 'Sounding could not resolve a base URL for HTTP request trials. Configure `sounding.request.baseUrl`, `sounding.browser.baseUrl`, or lift Sails with the HTTP hook.'
129
- )
269
+ throw createSoundingError({
270
+ code: 'E_SOUNDING_BASE_URL_UNRESOLVED',
271
+ message:
272
+ 'Sounding could not resolve a base URL for HTTP request trials. Configure `sounding.request.baseUrl`, `sounding.browser.baseUrl`, or lift Sails with the HTTP hook.',
273
+ })
130
274
  }
131
275
 
276
+ /**
277
+ * @param {{ sails?: SoundingSailsApp, getConfig?: () => AnyRecord, target: string, baseUrl?: string }} input
278
+ * @returns {string}
279
+ */
132
280
  function resolveUrl({ sails, getConfig, target, baseUrl }) {
133
281
  if (isAbsoluteUrl(target)) {
134
282
  return target
@@ -147,6 +295,11 @@ function resolveUrl({ sails, getConfig, target, baseUrl }) {
147
295
  return `${resolvedBaseUrl}/${target}`
148
296
  }
149
297
 
298
+ /**
299
+ * @param {string} method
300
+ * @param {any} payload
301
+ * @returns {any}
302
+ */
150
303
  function normalizePayload(method, payload) {
151
304
  if (payload === undefined) {
152
305
  return undefined
@@ -163,6 +316,160 @@ function normalizePayload(method, payload) {
163
316
  return payload
164
317
  }
165
318
 
319
+ /**
320
+ * @param {any} value
321
+ * @returns {string}
322
+ */
323
+ function normalizeEmail(value) {
324
+ return String(value || '')
325
+ .trim()
326
+ .toLowerCase()
327
+ }
328
+
329
+ /**
330
+ * @param {any} value
331
+ * @returns {value is string}
332
+ */
333
+ function looksLikeEmail(value) {
334
+ return typeof value === 'string' && value.includes('@')
335
+ }
336
+
337
+ /**
338
+ * @param {string[]} values
339
+ * @returns {string}
340
+ */
341
+ function formatAvailable(values) {
342
+ return values.length ? values.join(', ') : 'none'
343
+ }
344
+
345
+ /**
346
+ * @param {{ world?: SoundingWorldEngine, sails?: SoundingSailsApp, getConfig?: () => AnyRecord }} input
347
+ * @returns {string[]}
348
+ */
349
+ function availableWorldActors({ world, sails, getConfig }) {
350
+ const auth = resolveAuthConfig({ sails, getConfig })
351
+ const aliases = new Set()
352
+
353
+ for (const collection of [auth.worldCollection, 'users', 'creators']) {
354
+ const entries = world?.current?.[collection]
355
+
356
+ if (entries && typeof entries === 'object' && !Array.isArray(entries)) {
357
+ for (const alias of Object.keys(entries)) {
358
+ aliases.add(alias)
359
+ }
360
+ }
361
+ }
362
+
363
+ return Array.from(aliases).sort()
364
+ }
365
+
366
+ /**
367
+ * @param {{ actor: string, world?: SoundingWorldEngine, sails?: SoundingSailsApp, getConfig?: () => AnyRecord }} input
368
+ * @returns {SoundingActor | null}
369
+ */
370
+ function resolveWorldActor({ actor, world, sails, getConfig }) {
371
+ const auth = resolveAuthConfig({ sails, getConfig })
372
+
373
+ return (
374
+ world?.current?.[auth.worldCollection]?.[actor] ||
375
+ world?.current?.users?.[actor] ||
376
+ world?.current?.creators?.[actor] ||
377
+ world?.current?.[actor] ||
378
+ null
379
+ )
380
+ }
381
+
382
+ /**
383
+ * @param {{ actor: string, world?: SoundingWorldEngine, sails?: SoundingSailsApp, getConfig?: () => AnyRecord, email?: string, modelIdentity?: string }} input
384
+ * @returns {Error}
385
+ */
386
+ function createRequestActorUnresolvedError({
387
+ actor,
388
+ world,
389
+ sails,
390
+ getConfig,
391
+ email,
392
+ modelIdentity,
393
+ }) {
394
+ const availableActors = availableWorldActors({ world, sails, getConfig })
395
+ const availableMessage = `Available actors: ${formatAvailable(availableActors)}.`
396
+ const emailMessage = email ? ` Could not find ${modelIdentity || 'actor'} with email \`${email}\`.` : ''
397
+
398
+ return createSoundingError({
399
+ code: 'E_SOUNDING_REQUEST_ACTOR_UNRESOLVED',
400
+ message: `Sounding request.as() could not resolve actor \`${actor}\`. ${availableMessage}${emailMessage}`,
401
+ details: {
402
+ actor,
403
+ availableActors,
404
+ ...(email ? { email } : {}),
405
+ ...(modelIdentity ? { modelIdentity } : {}),
406
+ },
407
+ })
408
+ }
409
+
410
+ /**
411
+ * @param {{ actor: string, sails?: SoundingSailsApp, getConfig?: () => AnyRecord, world?: SoundingWorldEngine }} input
412
+ * @returns {Promise<SoundingActor>}
413
+ */
414
+ async function resolveEmailActor({ actor, sails, getConfig, world }) {
415
+ const auth = resolveAuthConfig({ sails, getConfig })
416
+ const email = normalizeEmail(actor)
417
+
418
+ if (!auth.model?.findOne) {
419
+ throw createRequestActorUnresolvedError({
420
+ actor,
421
+ world,
422
+ sails,
423
+ getConfig,
424
+ email,
425
+ modelIdentity: auth.modelIdentity,
426
+ })
427
+ }
428
+
429
+ const resolvedActor = await auth.model.findOne({ email })
430
+
431
+ if (!resolvedActor) {
432
+ throw createRequestActorUnresolvedError({
433
+ actor,
434
+ world,
435
+ sails,
436
+ getConfig,
437
+ email,
438
+ modelIdentity: auth.modelIdentity,
439
+ })
440
+ }
441
+
442
+ return resolvedActor
443
+ }
444
+
445
+ /**
446
+ * @param {SoundingActor} actor
447
+ * @returns {HeadersInit | AnyRecord}
448
+ */
449
+ function resolveActorHeaders(actor) {
450
+ return actor.headers || actor.sounding?.headers || {}
451
+ }
452
+
453
+ /**
454
+ * @param {SoundingActor} actor
455
+ * @param {AnyRecord} auth
456
+ * @returns {AnyRecord}
457
+ */
458
+ function resolveActorSession(actor, auth) {
459
+ return (
460
+ actor.session ||
461
+ actor.sounding?.session || {
462
+ ...(actor.id ? { [auth.sessionKey]: actor.id } : {}),
463
+ ...(actor.team ? { teamId: actor.team } : {}),
464
+ ...(actor.teamId ? { teamId: actor.teamId } : {}),
465
+ }
466
+ )
467
+ }
468
+
469
+ /**
470
+ * @param {{ sails?: SoundingSailsApp, getConfig?: () => AnyRecord, target: string, options?: SoundingRequestOptions }} input
471
+ * @returns {SoundingTransport}
472
+ */
166
473
  function resolveTransport({ sails, getConfig, target, options = {} }) {
167
474
  if (options.transport) {
168
475
  return options.transport
@@ -176,6 +483,12 @@ function resolveTransport({ sails, getConfig, target, options = {} }) {
176
483
  return requestConfig.transport || 'virtual'
177
484
  }
178
485
 
486
+ /**
487
+ * @param {string} method
488
+ * @param {string} target
489
+ * @param {any} payload
490
+ * @returns {string}
491
+ */
179
492
  function normalizeVirtualUrl(method, target, payload) {
180
493
  if (
181
494
  (method === 'GET' || method === 'HEAD' || method === 'DELETE') &&
@@ -194,6 +507,10 @@ function normalizeVirtualUrl(method, target, payload) {
194
507
  return target
195
508
  }
196
509
 
510
+ /**
511
+ * @param {AnyRecord} [session]
512
+ * @returns {(key: string, value?: any) => any[]}
513
+ */
197
514
  function createFlash(session = {}) {
198
515
  const flashStore = (session.__soundingFlashStore ||= {})
199
516
 
@@ -217,61 +534,173 @@ class MockClientResponse extends Transform {
217
534
  }
218
535
  }
219
536
 
537
+ let nextVirtualRequestId = 0
538
+
539
+ /**
540
+ * @param {AnyRecord} target
541
+ * @param {AnyRecord} source
542
+ */
543
+ function replaceObjectContents(target, source) {
544
+ const flashStore = target.__soundingFlashStore
545
+
546
+ for (const key of Object.keys(target)) {
547
+ delete target[key]
548
+ }
549
+
550
+ for (const key of Object.keys(source)) {
551
+ target[key] = source[key]
552
+ }
553
+
554
+ if (flashStore && !Object.prototype.hasOwnProperty.call(source, '__soundingFlashStore')) {
555
+ target.__soundingFlashStore = flashStore
556
+ }
557
+ }
558
+
559
+ /**
560
+ * @param {any} value
561
+ * @returns {any}
562
+ */
563
+ function cloneSessionValue(value) {
564
+ if (Array.isArray(value)) {
565
+ return value.map(cloneSessionValue)
566
+ }
567
+
568
+ if (isPlainObject(value)) {
569
+ return Object.fromEntries(
570
+ Object.entries(value).map(([key, nested]) => [key, cloneSessionValue(nested)])
571
+ )
572
+ }
573
+
574
+ return value
575
+ }
576
+
577
+ /**
578
+ * @param {AnyRecord} session
579
+ * @returns {AnyRecord}
580
+ */
581
+ function cloneSessionSnapshot(session) {
582
+ return cloneSessionValue(session)
583
+ }
584
+
585
+ /**
586
+ * Sails' virtual router deep-copies the partial request before routing it.
587
+ * Track the routed request so session mutations made by real route handlers
588
+ * can flow back into Sounding's shared virtual session object.
589
+ *
590
+ * @param {{ sails?: SoundingSailsApp, session: AnyRecord }} input
591
+ * @returns {{ requestId: string, sync(): void, close(): void }}
592
+ */
593
+ function createVirtualSessionTracker({ sails, session }) {
594
+ const requestId = `sounding-${++nextVirtualRequestId}`
595
+ let routedReq = null
596
+
597
+ function onRoute(requestState) {
598
+ if (requestState?.req?._soundingRequestId === requestId) {
599
+ routedReq = requestState.req
600
+
601
+ if (isPlainObject(routedReq.session)) {
602
+ routedReq.flash = createFlash(routedReq.session)
603
+ }
604
+ }
605
+ }
606
+
607
+ if (typeof sails?.on === 'function') {
608
+ sails.on('router:route', onRoute)
609
+ }
610
+
611
+ return {
612
+ requestId,
613
+ sync() {
614
+ if (isPlainObject(session) && isPlainObject(routedReq?.session)) {
615
+ replaceObjectContents(session, routedReq.session)
616
+ }
617
+ },
618
+ close() {
619
+ if (typeof sails?.off === 'function') {
620
+ sails.off('router:route', onRoute)
621
+ return
622
+ }
623
+
624
+ if (typeof sails?.removeListener === 'function') {
625
+ sails.removeListener('router:route', onRoute)
626
+ }
627
+ },
628
+ }
629
+ }
630
+
631
+ /**
632
+ * @param {{ sails?: SoundingSailsApp }} input
633
+ * @returns {{ send(method: string, target: string, payload?: any, options?: SoundingRequestOptions): Promise<SoundingResponse> }}
634
+ */
220
635
  function createVirtualTransport({ sails }) {
221
636
  if (typeof sails?.router?.route !== 'function') {
222
- throw new Error(
223
- 'Sounding could not find `sails.router.route()`. Virtual request transport requires a loaded Sails app.'
224
- )
637
+ throw createSoundingError({
638
+ code: 'E_SOUNDING_VIRTUAL_TRANSPORT_UNAVAILABLE',
639
+ message:
640
+ 'Sounding could not find `sails.router.route()`. Virtual request transport requires a loaded Sails app.',
641
+ })
225
642
  }
226
643
 
227
644
  return {
228
645
  async send(method, target, payload, options = {}) {
229
646
  return new Promise((resolve, reject) => {
230
647
  const session = options.session || defaultSessionState()
648
+ const sessionTracker = createVirtualSessionTracker({ sails, session })
649
+ const virtualUrl = normalizeVirtualUrl(method, target, normalizePayload(method, payload))
650
+ /** @type {MockClientResponse & { body?: any, headers?: AnyRecord, statusCode?: number, statusMessage?: string }} */
231
651
  const clientRes = new MockClientResponse()
232
652
 
233
653
  try {
234
654
  clientRes.on('finish', () => {
235
655
  try {
656
+ sessionTracker.sync()
236
657
  clientRes.body = clientRes.read()
237
658
  clientRes.body = clientRes.body?.toString()
238
- } catch {}
239
659
 
240
- if (!clientRes.body) {
241
- delete clientRes.body
660
+ if (!clientRes.body) {
661
+ delete clientRes.body
662
+ }
663
+
664
+ const status = clientRes.statusCode || 500
665
+ const responseBody = clientRes.body
666
+ const requestHeaders = normalizeRequestHeadersMetadata(options.headers)
667
+
668
+ resolve(
669
+ normalizeResponse({
670
+ raw: clientRes,
671
+ status,
672
+ statusText: clientRes.statusMessage || '',
673
+ headers: clientRes.headers || {},
674
+ url: target,
675
+ redirected: status >= 300 && status < 400,
676
+ responseBody,
677
+ session: cloneSessionSnapshot(session),
678
+ request: {
679
+ method,
680
+ target,
681
+ transport: 'virtual',
682
+ url: virtualUrl,
683
+ ...(requestHeaders ? { headers: requestHeaders } : {}),
684
+ },
685
+ })
686
+ )
687
+ } catch (error) {
688
+ reject(error)
689
+ } finally {
690
+ sessionTracker.close()
242
691
  }
243
-
244
- if (
245
- clientRes.body !== undefined &&
246
- clientRes.headers?.['content-type'] === 'application/json'
247
- ) {
248
- clientRes.body = JSON.parse(clientRes.body)
249
- }
250
-
251
- const status = clientRes.statusCode || 500
252
- const responseBody = clientRes.body
253
-
254
- resolve(
255
- normalizeResponse({
256
- raw: clientRes,
257
- status,
258
- statusText: clientRes.statusMessage || '',
259
- headers: clientRes.headers || {},
260
- url: target,
261
- redirected: status >= 300 && status < 400,
262
- responseBody,
263
- })
264
- )
265
692
  })
266
693
 
267
694
  clientRes.on('error', (error) => {
268
- reject(error || new Error('Error on virtual response stream'))
695
+ sessionTracker.close()
696
+ reject(createVirtualResponseStreamError(error))
269
697
  })
270
698
 
271
699
  sails.router.route(
272
700
  {
273
701
  method,
274
- url: normalizeVirtualUrl(method, target, normalizePayload(method, payload)),
702
+ url: virtualUrl,
703
+ _soundingRequestId: sessionTracker.requestId,
275
704
  body: ['GET', 'HEAD', 'DELETE'].includes(method)
276
705
  ? undefined
277
706
  : normalizePayload(method, payload),
@@ -287,6 +716,7 @@ function createVirtualTransport({ sails }) {
287
716
  }
288
717
  )
289
718
  } catch (error) {
719
+ sessionTracker.close()
290
720
  reject(error)
291
721
  return
292
722
  }
@@ -295,10 +725,19 @@ function createVirtualTransport({ sails }) {
295
725
  }
296
726
  }
297
727
 
728
+ /**
729
+ * @returns {AnyRecord}
730
+ */
298
731
  function defaultSessionState() {
299
732
  return {}
300
733
  }
301
734
 
735
+ /**
736
+ * @param {string} method
737
+ * @param {any} payload
738
+ * @param {Headers} headers
739
+ * @returns {{ body: any, headers: Headers }}
740
+ */
302
741
  function normalizeBodyAndHeaders(method, payload, headers) {
303
742
  if (payload === undefined || method === 'GET' || method === 'HEAD') {
304
743
  return {
@@ -337,13 +776,24 @@ function normalizeBodyAndHeaders(method, payload, headers) {
337
776
  }
338
777
  }
339
778
 
779
+ /**
780
+ * @param {{
781
+ * sails?: SoundingSailsApp,
782
+ * getConfig?: () => AnyRecord,
783
+ * fetchImplementation?: typeof fetch,
784
+ * }} input
785
+ * @returns {{ send(method: string, target: string, payload?: any, options?: SoundingRequestOptions): Promise<SoundingResponse> }}
786
+ */
340
787
  function createHttpTransport({
341
788
  sails,
342
789
  getConfig,
343
790
  fetchImplementation = globalThis.fetch,
344
791
  }) {
345
792
  if (typeof fetchImplementation !== 'function') {
346
- throw new Error('Sounding could not find a fetch implementation for HTTP request trials.')
793
+ throw createSoundingError({
794
+ code: 'E_SOUNDING_HTTP_FETCH_UNAVAILABLE',
795
+ message: 'Sounding could not find a fetch implementation for HTTP request trials.',
796
+ })
347
797
  }
348
798
 
349
799
  return {
@@ -354,14 +804,16 @@ function createHttpTransport({
354
804
  })
355
805
 
356
806
  const { body, headers: finalHeaders } = normalizeBodyAndHeaders(method, payload, headers)
807
+ const requestHeaders = normalizeRequestHeadersMetadata(finalHeaders)
808
+ const url = resolveUrl({
809
+ sails,
810
+ getConfig,
811
+ target,
812
+ baseUrl: options.baseUrl,
813
+ })
357
814
 
358
815
  const response = await fetchImplementation(
359
- resolveUrl({
360
- sails,
361
- getConfig,
362
- target,
363
- baseUrl: options.baseUrl,
364
- }),
816
+ url,
365
817
  {
366
818
  method,
367
819
  redirect: options.redirect || 'manual',
@@ -379,11 +831,31 @@ function createHttpTransport({
379
831
  url: response.url,
380
832
  redirected: response.redirected,
381
833
  responseBody: await response.text(),
834
+ request: {
835
+ method,
836
+ target,
837
+ transport: 'http',
838
+ url,
839
+ ...(requestHeaders ? { headers: requestHeaders } : {}),
840
+ },
382
841
  })
383
842
  },
384
843
  }
385
844
  }
386
845
 
846
+ /**
847
+ * @param {{
848
+ * sails?: SoundingSailsApp,
849
+ * getConfig?: () => AnyRecord,
850
+ * fetchImplementation?: typeof fetch,
851
+ * defaultHeaders?: HeadersInit | AnyRecord,
852
+ * defaultSession?: AnyRecord,
853
+ * transportOverride?: SoundingTransport,
854
+ * world?: SoundingWorldEngine,
855
+ * defaultActor?: string,
856
+ * }} [options]
857
+ * @returns {SoundingRequestClient}
858
+ */
387
859
  function createRequestClient({
388
860
  sails,
389
861
  getConfig,
@@ -391,9 +863,20 @@ function createRequestClient({
391
863
  defaultHeaders = {},
392
864
  defaultSession = {},
393
865
  transportOverride,
866
+ world,
867
+ defaultActor,
394
868
  } = {}) {
395
869
  let virtualTransport = null
396
870
  let httpTransport = null
871
+ let defaultActorContextPromise = null
872
+
873
+ function resetDefaultSession() {
874
+ for (const key of Reflect.ownKeys(defaultSession)) {
875
+ Reflect.deleteProperty(defaultSession, key)
876
+ }
877
+
878
+ defaultActorContextPromise = null
879
+ }
397
880
 
398
881
  function getVirtualTransport() {
399
882
  virtualTransport ||= createVirtualTransport({ sails })
@@ -409,20 +892,80 @@ function createRequestClient({
409
892
  return httpTransport
410
893
  }
411
894
 
895
+ /**
896
+ * @returns {Promise<{ headers: HeadersInit | AnyRecord, session: AnyRecord } | null>}
897
+ */
898
+ async function getDefaultActorContext() {
899
+ if (!defaultActor) {
900
+ return null
901
+ }
902
+
903
+ defaultActorContextPromise ||= resolveEmailActor({
904
+ actor: defaultActor,
905
+ sails,
906
+ getConfig,
907
+ world,
908
+ }).then((actor) => {
909
+ const auth = resolveAuthConfig({ sails, getConfig })
910
+
911
+ return {
912
+ headers: resolveActorHeaders(actor),
913
+ session: {
914
+ ...defaultSession,
915
+ ...resolveActorSession(actor, auth),
916
+ },
917
+ }
918
+ })
919
+
920
+ return defaultActorContextPromise
921
+ }
922
+
923
+ /**
924
+ * @param {SoundingActor} actor
925
+ * @returns {SoundingRequestClient}
926
+ */
927
+ function withActor(actor) {
928
+ const auth = resolveAuthConfig({ sails, getConfig })
929
+
930
+ return createRequestClient({
931
+ sails,
932
+ getConfig,
933
+ fetchImplementation,
934
+ defaultHeaders: {
935
+ ...defaultHeaders,
936
+ ...resolveActorHeaders(actor),
937
+ },
938
+ defaultSession: {
939
+ ...defaultSession,
940
+ ...resolveActorSession(actor, auth),
941
+ },
942
+ transportOverride,
943
+ world,
944
+ })
945
+ }
946
+
412
947
  async function send(method, target, payloadOrOptions, maybeOptions) {
413
948
  const hasPayload = !['GET', 'HEAD'].includes(method)
414
949
  const payload = hasPayload ? payloadOrOptions : undefined
415
950
  const options = (hasPayload ? maybeOptions : payloadOrOptions) || {}
951
+ const defaultActorContext = await getDefaultActorContext()
952
+ const baseHeaders = defaultActorContext
953
+ ? {
954
+ ...defaultHeaders,
955
+ ...defaultActorContext.headers,
956
+ }
957
+ : defaultHeaders
958
+ const baseSession = defaultActorContext?.session || defaultSession
416
959
  const headers = {
417
- ...defaultHeaders,
960
+ ...baseHeaders,
418
961
  ...(options.headers || {}),
419
962
  }
420
963
  const session = options.session
421
964
  ? {
422
- ...defaultSession,
965
+ ...baseSession,
423
966
  ...options.session,
424
967
  }
425
- : defaultSession
968
+ : baseSession
426
969
  const transport = resolveTransport({
427
970
  sails,
428
971
  getConfig,
@@ -447,7 +990,13 @@ function createRequestClient({
447
990
  return getHttpTransport().send(method, target, payload, transportOptions)
448
991
  }
449
992
 
450
- throw new Error(`Unknown Sounding request transport: ${transport}`)
993
+ throw createSoundingError({
994
+ code: 'E_SOUNDING_UNKNOWN_TRANSPORT',
995
+ message: `Unknown Sounding request transport: ${transport}`,
996
+ details: {
997
+ transport,
998
+ },
999
+ })
451
1000
  }
452
1001
 
453
1002
  return {
@@ -483,6 +1032,10 @@ function createRequestClient({
483
1032
  return send('DELETE', target, payload, options)
484
1033
  },
485
1034
 
1035
+ clearSession() {
1036
+ resetDefaultSession()
1037
+ },
1038
+
486
1039
  withHeaders(headers = {}) {
487
1040
  return createRequestClient({
488
1041
  sails,
@@ -494,6 +1047,8 @@ function createRequestClient({
494
1047
  },
495
1048
  defaultSession,
496
1049
  transportOverride,
1050
+ world,
1051
+ defaultActor,
497
1052
  })
498
1053
  },
499
1054
 
@@ -508,6 +1063,8 @@ function createRequestClient({
508
1063
  ...session,
509
1064
  },
510
1065
  transportOverride,
1066
+ world,
1067
+ defaultActor,
511
1068
  })
512
1069
  },
513
1070
 
@@ -519,6 +1076,8 @@ function createRequestClient({
519
1076
  defaultHeaders,
520
1077
  defaultSession,
521
1078
  transportOverride: transport,
1079
+ world,
1080
+ defaultActor,
522
1081
  })
523
1082
  },
524
1083
 
@@ -527,16 +1086,35 @@ function createRequestClient({
527
1086
  return this
528
1087
  }
529
1088
 
530
- const auth = resolveAuthConfig({ sails, getConfig })
531
- const actorHeaders = actor.headers || actor.sounding?.headers || {}
532
- const actorSession = actor.session ||
533
- actor.sounding?.session || {
534
- ...(actor.id ? { [auth.sessionKey]: actor.id } : {}),
535
- ...(actor.team ? { teamId: actor.team } : {}),
536
- ...(actor.teamId ? { teamId: actor.teamId } : {}),
1089
+ if (typeof actor === 'string') {
1090
+ if (looksLikeEmail(actor)) {
1091
+ return createRequestClient({
1092
+ sails,
1093
+ getConfig,
1094
+ fetchImplementation,
1095
+ defaultHeaders,
1096
+ defaultSession,
1097
+ transportOverride,
1098
+ world,
1099
+ defaultActor: actor,
1100
+ })
537
1101
  }
538
1102
 
539
- return this.withHeaders(actorHeaders).withSession(actorSession)
1103
+ const resolvedActor = resolveWorldActor({ actor, world, sails, getConfig })
1104
+
1105
+ if (!resolvedActor) {
1106
+ throw createRequestActorUnresolvedError({
1107
+ actor,
1108
+ world,
1109
+ sails,
1110
+ getConfig,
1111
+ })
1112
+ }
1113
+
1114
+ return withActor(resolvedActor)
1115
+ }
1116
+
1117
+ return withActor(actor)
540
1118
  },
541
1119
  }
542
1120
  }