Skip to content

Commit 83fb677

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/js-sdk-audit-6x115l-h6-action-attempt-types
2 parents 6a78b5b + 31b4a93 commit 83fb677

12 files changed

Lines changed: 642 additions & 37 deletions

.c8rc.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
{
22
"exclude": [
3-
"**/index.ts",
43
"package/**/*.ts",
54
"examples/**/*.ts",
65
"**/*.test.ts",

README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,7 @@ const pages = seam.createPaginator(
350350
)
351351

352352
for await (const device of pages.flatten()) {
353-
console.log(devices.name)
353+
console.log(device.display_name)
354354
}
355355
```
356356

@@ -474,7 +474,8 @@ default.
474474

475475
The Axios client and retry behavior may be configured with custom initiation options
476476
via [`axiosOptions`][axiosOptions] and [`axiosRetryOptions`][axiosRetryOptions].
477-
Options are deep merged with the default options.
477+
Options are shallow merged with the default options:
478+
each provided top-level option replaces the default value.
478479

479480
By default, the SDK makes up to three attempts: the initial request and two
480481
retries. Retries are limited to `GET`, `HEAD`, `OPTIONS`, `PUT`, and `DELETE`
@@ -537,6 +538,12 @@ console.log(`${request.method} ${request.url}`, JSON.stringify(request.body))
537538
const devices = await request.execute()
538539
```
539540

541+
A `SeamHttpRequest` is sent at most once.
542+
Awaiting the same request again,
543+
or calling `execute`, `then`, `catch`, or `finally` more than once,
544+
always returns the result of the first execution
545+
and never repeats the HTTP request.
546+
540547
#### Serializing URL search params
541548

542549
The Seam API parses URL search params as complex types.

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
{
22
"name": "@seamapi/http",
3-
"version": "2.23.0",
3+
"version": "2.23.5",
44
"description": "JavaScript HTTP client for the Seam API written in TypeScript.",
55
"type": "module",
66
"main": "index.js",
77
"types": "index.d.ts",
88
"exports": {
99
".": {
1010
"types": "./index.d.ts",
11-
"import": "./index.js"
11+
"default": "./index.js"
1212
},
1313
"./connect": {
1414
"types": "./index.d.ts",

src/lib/seam-http-error.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,38 @@ export const isSeamHttpInvalidInputError = (
103103
): error is SeamHttpInvalidInputError => {
104104
return error instanceof SeamHttpInvalidInputError
105105
}
106+
107+
/**
108+
* Error thrown when the Seam API returns a success response
109+
* with an unexpected shape,
110+
* e.g., a response missing the expected response key.
111+
*/
112+
export class SeamHttpInvalidResponseError extends Error {
113+
/**
114+
* Path of the endpoint that returned the invalid response.
115+
*/
116+
path: string
117+
118+
/**
119+
* Key expected to contain the response data.
120+
*/
121+
responseKey: string
122+
123+
constructor(path: string, responseKey: string, reason: string) {
124+
super(
125+
`Seam returned an invalid response for ${path}: expected "${responseKey}", ${reason}`,
126+
)
127+
this.name = this.constructor.name
128+
this.path = path
129+
this.responseKey = responseKey
130+
}
131+
}
132+
133+
/**
134+
* Returns true if the error is a {@link SeamHttpInvalidResponseError}.
135+
*/
136+
export const isSeamHttpInvalidResponseError = (
137+
error: unknown,
138+
): error is SeamHttpInvalidResponseError => {
139+
return error instanceof SeamHttpInvalidResponseError
140+
}

src/lib/seam-http-request.ts

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
resolveActionAttempt,
99
} from './resolve-action-attempt.js'
1010
import type { ActionAttempt } from './resources/action-attempt.js'
11+
import { SeamHttpInvalidResponseError } from './seam-http-error.js'
1112
import { serializeUrlSearchParams } from './url-search-params-serializer.js'
1213

1314
interface 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+
225291
const 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
}

src/lib/seam-paginator.ts

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Client } from './client.js'
22
import type { SeamHttpRequestOptions } from './options.js'
3-
import { SeamHttpRequest } from './seam-http-request.js'
3+
import { SeamHttpInvalidResponseError } from './seam-http-error.js'
4+
import { readResponseData, SeamHttpRequest } from './seam-http-request.js'
45

56
interface SeamPaginatorParent {
67
readonly client: Client
@@ -96,27 +97,38 @@ export class SeamPaginator<
9697
})
9798

9899
const response = await request.fetchResponse()
99-
const data = response[responseKey]
100+
const data = readResponseData(response, responseKey, request.pathname)
100101

101-
const paginationData =
102-
response != null &&
103-
typeof response === 'object' &&
104-
'pagination' in response
105-
? (response.pagination as PaginationData)
106-
: null
107-
108-
const pagination: Pagination = {
109-
hasNextPage: paginationData?.has_next_page ?? false,
110-
nextPageCursor: paginationData?.next_page_cursor ?? null,
111-
nextPageUrl: paginationData?.next_page_url ?? null,
102+
if (!Array.isArray(data)) {
103+
throw new SeamHttpInvalidResponseError(
104+
request.pathname,
105+
String(responseKey),
106+
`got ${data === null ? 'null' : typeof data} instead of a list`,
107+
)
112108
}
113109

114-
if (!Array.isArray(data)) {
115-
throw new Error(
116-
`Expected an array response for ${String(responseKey)} but got ${String(typeof data)}`,
110+
const paginationData = readResponseData(
111+
response as { pagination: unknown },
112+
'pagination',
113+
request.pathname,
114+
)
115+
116+
if (paginationData === null || typeof paginationData !== 'object') {
117+
throw new SeamHttpInvalidResponseError(
118+
request.pathname,
119+
'pagination',
120+
`got ${paginationData === null ? 'null' : typeof paginationData} instead of a pagination object`,
117121
)
118122
}
119123

124+
const paginationResponse = paginationData as PaginationData
125+
126+
const pagination: Pagination = {
127+
hasNextPage: paginationResponse.has_next_page ?? false,
128+
nextPageCursor: paginationResponse.next_page_cursor ?? null,
129+
nextPageUrl: paginationResponse.next_page_url ?? null,
130+
}
131+
120132
return [
121133
data as EnsureReadonlyArray<TResponse[TResponseKey]>,
122134
pagination,
@@ -142,9 +154,7 @@ export class SeamPaginator<
142154
/**
143155
* Yields each item across all pages, fetching the next page as needed.
144156
*/
145-
async *flatten(): AsyncGenerator<
146-
EnsureReadonlyArray<TResponse[TResponseKey]>
147-
> {
157+
async *flatten(): AsyncGenerator<ElementOfArray<TResponse[TResponseKey]>> {
148158
let [current, pagination] = await this.firstPage()
149159
for (const item of current) {
150160
yield item
@@ -174,6 +184,8 @@ export class SeamPaginator<
174184

175185
type EnsureReadonlyArray<T> = T extends readonly any[] ? T : never
176186
type EnsureMutableArray<T> = T extends any[] ? T : never
187+
type ElementOfArray<T> =
188+
T extends ReadonlyArray<infer TElement> ? TElement : never
177189

178190
interface PaginationData {
179191
has_next_page: boolean

0 commit comments

Comments
 (0)