88 resolveActionAttempt ,
99} from './resolve-action-attempt.js'
1010import type { ActionAttempt } from './resources/action-attempt.js'
11+ import { SeamHttpInvalidResponseError } from './seam-http-error.js'
1112import { serializeUrlSearchParams } from './url-search-params-serializer.js'
1213
1314interface SeamHttpRequestParent {
@@ -36,6 +37,11 @@ interface SeamHttpRequestConfig<TResponseKey> {
3637 * The request is sent once `execute` is called,
3738 * or when the request is awaited like a Promise,
3839 * e.g., with `await`, `then`, `catch`, or `finally`.
40+ * The request is sent at most once:
41+ * awaiting the same SeamHttpRequest again,
42+ * or calling `execute`, `then`, `catch`, or `finally` more than once,
43+ * always returns the result of the first execution
44+ * and never repeats the HTTP request.
3945 * When the response contains an action attempt,
4046 * awaiting the request also waits for the action attempt to resolve
4147 * according to the `waitForActionAttempt` option.
@@ -54,6 +60,12 @@ export class SeamHttpRequest<
5460 readonly #parent: SeamHttpRequestParent
5561 readonly #config: SeamHttpRequestConfig < TResponseKey >
5662
63+ #executePromise: Promise <
64+ TResponseKey extends keyof TResponse ? TResponse [ TResponseKey ] : undefined
65+ > | null = null
66+
67+ #fetchResponsePromise: Promise < TResponse > | null = null
68+
5769 constructor (
5870 parent : SeamHttpRequestParent ,
5971 config : SeamHttpRequestConfig < TResponseKey > ,
@@ -115,9 +127,19 @@ export class SeamHttpRequest<
115127 * If the response contains an action attempt,
116128 * waits for the action attempt to resolve
117129 * according to the `waitForActionAttempt` option.
130+ * The request is sent at most once:
131+ * calling this method again returns the result of the first call
132+ * and never repeats the HTTP request.
118133 */
119134 async execute ( ) : Promise <
120135 TResponseKey extends keyof TResponse ? TResponse [ TResponseKey ] : undefined
136+ > {
137+ this . #executePromise ??= this . #execute( )
138+ return await this . #executePromise
139+ }
140+
141+ async #execute( ) : Promise <
142+ TResponseKey extends keyof TResponse ? TResponse [ TResponseKey ] : undefined
121143 > {
122144 const response = await this . fetchResponse ( )
123145
@@ -129,7 +151,11 @@ export class SeamHttpRequest<
129151 return undefined as Response
130152 }
131153
132- const data = response [ this . responseKey ] as unknown as Response
154+ const data = readResponseData (
155+ response ,
156+ this . responseKey ,
157+ this . pathname ,
158+ ) as Response
133159
134160 if ( this . responseKey === 'action_attempt' ) {
135161 const waitForActionAttempt =
@@ -157,8 +183,16 @@ export class SeamHttpRequest<
157183 /**
158184 * Sends the request and returns the entire response body
159185 * without waiting for any action attempt to resolve.
186+ * The request is sent at most once:
187+ * calling this method again returns the result of the first call
188+ * and never repeats the HTTP request.
160189 */
161190 async fetchResponse ( ) : Promise < TResponse > {
191+ this . #fetchResponsePromise ??= this . #fetchResponse( )
192+ return await this . #fetchResponsePromise
193+ }
194+
195+ async #fetchResponse( ) : Promise < TResponse > {
162196 assertValidRequestParameters (
163197 this . #config. parameters ,
164198 this . pathname ,
@@ -229,8 +263,40 @@ const getParamsSerializer = (
229263 return serializeUrlSearchParams
230264}
231265
266+ /**
267+ * Reads the response data at the response key,
268+ * throwing a {@link SeamHttpInvalidResponseError} for a success response
269+ * that is not an object or does not contain the response key.
270+ */
271+ export const readResponseData = <
272+ TResponse ,
273+ TResponseKey extends keyof TResponse ,
274+ > (
275+ response : TResponse ,
276+ responseKey : TResponseKey ,
277+ path : string ,
278+ ) : TResponse [ TResponseKey ] => {
279+ if ( response == null || typeof response !== 'object' ) {
280+ throw new SeamHttpInvalidResponseError (
281+ path ,
282+ String ( responseKey ) ,
283+ `got ${ response === null ? 'null' : typeof response } instead of a response object` ,
284+ )
285+ }
286+
287+ if ( ! ( responseKey in response ) ) {
288+ throw new SeamHttpInvalidResponseError (
289+ path ,
290+ String ( responseKey ) ,
291+ 'which the response does not contain' ,
292+ )
293+ }
294+
295+ return response [ responseKey ]
296+ }
297+
232298const getUrlPrefix = ( input : string ) : string => {
233- if ( canParseUrl ( input ) ) {
299+ if ( isAbsoluteHttpUrl ( input ) ) {
234300 const url = new URL ( input ) . toString ( )
235301 if ( url . endsWith ( '/' ) ) return url . slice ( 0 , - 1 )
236302 return url
@@ -246,11 +312,13 @@ const getUrlPrefix = (input: string): string => {
246312 )
247313}
248314
249- // UPSTREAM: Prefer URL.canParse when it has wider support.
250- // https://caniuse.com/mdn-api_url_canparse_static
251- const canParseUrl = ( input : string ) : boolean => {
315+ // An input without an http or https scheme, e.g., localhost:3000,
316+ // may still parse as a URL with an unintended scheme, e.g., localhost:,
317+ // and must not be treated as an absolute URL.
318+ const isAbsoluteHttpUrl = ( input : string ) : boolean => {
252319 try {
253- return new URL ( input ) != null
320+ const { protocol } = new URL ( input )
321+ return protocol === 'http:' || protocol === 'https:'
254322 } catch {
255323 return false
256324 }
0 commit comments