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 > ,
@@ -118,9 +130,19 @@ export class SeamHttpRequest<
118130 * If the response contains an action attempt,
119131 * waits for the action attempt to resolve
120132 * according to the `waitForActionAttempt` option.
133+ * The request is sent at most once:
134+ * calling this method again returns the result of the first call
135+ * and never repeats the HTTP request.
121136 */
122137 async execute ( ) : Promise <
123138 TResponseKey extends keyof TResponse ? TResponse [ TResponseKey ] : undefined
139+ > {
140+ this . #executePromise ??= this . #execute( )
141+ return await this . #executePromise
142+ }
143+
144+ async #execute( ) : Promise <
145+ TResponseKey extends keyof TResponse ? TResponse [ TResponseKey ] : undefined
124146 > {
125147 const response = await this . fetchResponse ( )
126148
@@ -132,7 +154,11 @@ export class SeamHttpRequest<
132154 return undefined as Response
133155 }
134156
135- const data = response [ this . responseKey ] as unknown as Response
157+ const data = readResponseData (
158+ response ,
159+ this . responseKey ,
160+ this . pathname ,
161+ ) as Response
136162
137163 if ( this . responseKey === 'action_attempt' ) {
138164 const waitForActionAttempt =
@@ -160,8 +186,16 @@ export class SeamHttpRequest<
160186 /**
161187 * Sends the request and returns the entire response body
162188 * without waiting for any action attempt to resolve.
189+ * The request is sent at most once:
190+ * calling this method again returns the result of the first call
191+ * and never repeats the HTTP request.
163192 */
164193 async fetchResponse ( ) : Promise < TResponse > {
194+ this . #fetchResponsePromise ??= this . #fetchResponse( )
195+ return await this . #fetchResponsePromise
196+ }
197+
198+ async #fetchResponse( ) : Promise < TResponse > {
165199 assertValidRequestParameters (
166200 this . #config. parameters ,
167201 this . pathname ,
@@ -222,8 +256,40 @@ export class SeamHttpRequest<
222256 }
223257}
224258
259+ /**
260+ * Reads the response data at the response key,
261+ * throwing a {@link SeamHttpInvalidResponseError} for a success response
262+ * that is not an object or does not contain the response key.
263+ */
264+ export const readResponseData = <
265+ TResponse ,
266+ TResponseKey extends keyof TResponse ,
267+ > (
268+ response : TResponse ,
269+ responseKey : TResponseKey ,
270+ path : string ,
271+ ) : TResponse [ TResponseKey ] => {
272+ if ( response == null || typeof response !== 'object' ) {
273+ throw new SeamHttpInvalidResponseError (
274+ path ,
275+ String ( responseKey ) ,
276+ `got ${ response === null ? 'null' : typeof response } instead of a response object` ,
277+ )
278+ }
279+
280+ if ( ! ( responseKey in response ) ) {
281+ throw new SeamHttpInvalidResponseError (
282+ path ,
283+ String ( responseKey ) ,
284+ 'which the response does not contain' ,
285+ )
286+ }
287+
288+ return response [ responseKey ]
289+ }
290+
225291const getUrlPrefix = ( input : string ) : string => {
226- if ( canParseUrl ( input ) ) {
292+ if ( isAbsoluteHttpUrl ( input ) ) {
227293 const url = new URL ( input ) . toString ( )
228294 if ( url . endsWith ( '/' ) ) return url . slice ( 0 , - 1 )
229295 return url
@@ -239,11 +305,13 @@ const getUrlPrefix = (input: string): string => {
239305 )
240306}
241307
242- // UPSTREAM: Prefer URL.canParse when it has wider support.
243- // https://caniuse.com/mdn-api_url_canparse_static
244- const canParseUrl = ( input : string ) : boolean => {
308+ // An input without an http or https scheme, e.g., localhost:3000,
309+ // may still parse as a URL with an unintended scheme, e.g., localhost:,
310+ // and must not be treated as an absolute URL.
311+ const isAbsoluteHttpUrl = ( input : string ) : boolean => {
245312 try {
246- return new URL ( input ) != null
313+ const { protocol } = new URL ( input )
314+ return protocol === 'http:' || protocol === 'https:'
247315 } catch {
248316 return false
249317 }
0 commit comments