tempest-react-sdk 0.79.0 → 0.80.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.
- package/bin/lib/openapi/generate.hostile.test.mjs +45 -4
- package/bin/lib/openapi/generate.mjs +145 -34
- package/bin/lib/openapi/generate.test.mjs +203 -4
- package/bin/lib/openapi/schema-to-zod.mjs +4 -3
- package/dist/http/api-client.cjs +1 -1
- package/dist/http/api-client.cjs.map +1 -1
- package/dist/http/api-client.js +5 -1
- package/dist/http/api-client.js.map +1 -1
- package/dist/http/build-url.cjs +1 -1
- package/dist/http/build-url.cjs.map +1 -1
- package/dist/http/build-url.js +3 -1
- package/dist/http/build-url.js.map +1 -1
- package/dist/http/presigned-post.cjs +2 -0
- package/dist/http/presigned-post.cjs.map +1 -0
- package/dist/http/presigned-post.js +73 -0
- package/dist/http/presigned-post.js.map +1 -0
- package/dist/sse/create-event-stream.cjs +1 -1
- package/dist/sse/create-event-stream.cjs.map +1 -1
- package/dist/sse/create-event-stream.js +44 -30
- package/dist/sse/create-event-stream.js.map +1 -1
- package/dist/sse/ticket-event-stream.cjs +2 -0
- package/dist/sse/ticket-event-stream.cjs.map +1 -0
- package/dist/sse/ticket-event-stream.js +41 -0
- package/dist/sse/ticket-event-stream.js.map +1 -0
- package/dist/sse/use-event-stream.cjs +1 -1
- package/dist/sse/use-event-stream.cjs.map +1 -1
- package/dist/sse/use-event-stream.js +7 -4
- package/dist/sse/use-event-stream.js.map +1 -1
- package/dist/tempest-react-sdk.cjs +1 -1
- package/dist/tempest-react-sdk.d.ts +206 -4
- package/dist/tempest-react-sdk.js +122 -120
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api-client.cjs","names":["TempestApiError","isRetriableStatus","buildApiError","isTrustedCredentialTarget","buildApiUrl","csrfHeaders","withTimeout","SessionSupersededError","randomId","retryWithBackoff","decodeByContentType","require_ids","require_build_url","require_credential_scope","require_csrf","require_decode_response","require_errors","require_retry","require_timeout","IDEMPOTENT_METHODS","isRetriableFailure","method","error","resolveRetry","config","DEFAULT_TIMEOUT","DEFAULT_UPLOAD_TIMEOUT","isFormData","body","elapsedMs","startedAt","parseError","response","sentRequestId","createApiClient","fetcher","authHeaders","skipAuth","token","url","rawRequest","rest","options","isForm","configured","limit","timeout","path","callerHeaders","headers","finalHeaders","requestId","timed","signal","init","cause","send","log","entry","line","notifyUnauthorized","context","authContext","attempt","sent","skipAuthRetry","replayed","decode","run","perRequest","retryOptions","request","upload","formData"],"sources":["../../src/http/api-client.ts"],"sourcesContent":["/**\n * @tempest-limits function-lines — createApiClient is over the limit and every line\n * is a request-lifecycle concern the client cannot delegate: base URL joining, the\n * auth header, the 401 refresh-and-replay, the opt-in retry wrapper and the response\n * parsing that turns a failure into a typed error.\n */\nimport { randomId } from \"../utils\";\nimport { buildApiUrl } from \"./build-url\";\nimport { isTrustedCredentialTarget, reportSuppressedCredential } from \"./credential-scope\";\nimport { csrfHeaders } from \"./csrf\";\nimport { decodeByContentType } from \"./decode-response\";\nimport type { ResponseDecoder } from \"./decode-response\";\nimport {\n buildApiError,\n SessionSupersededError,\n TempestApiError,\n isRetriableStatus,\n} from \"./errors\";\nimport { retry as retryWithBackoff } from \"./retry\";\nimport type { RetryOptions } from \"./retry\";\nimport { withTimeout } from \"./timeout\";\nimport type { ApiClient, ApiClientConfig, RequestAuthContext, RequestOptions } from \"./types\";\n\n/**\n * Methods the built-in retry policy will replay.\n *\n * `PUT` and `DELETE` are idempotent on paper but stay out: a backend that logs,\n * bills, or fires a webhook per call still sees two, so replaying them is a\n * decision the caller makes through `shouldRetry`, not a default.\n */\nconst IDEMPOTENT_METHODS: ReadonlySet<string> = new Set([\"GET\", \"HEAD\", \"OPTIONS\"]);\n\n/**\n * Sub-500 statuses worth a second attempt: a network failure (status `0`), a\n * request timeout, a too-early replay, and a rate limit — which usually carries\n * the `Retry-After` the backoff already honours.\n */\n\n/**\n * The built-in retry policy, used when `retry` is `true` or is options carrying\n * no `shouldRetry` of their own.\n *\n * Conservative on purpose. Replaying a write can duplicate it, and replaying a\n * `400` or a `403` cannot fix a bad payload or a permission the caller does not\n * have — it only spends the user's time before showing the same error.\n *\n * @param error - Whatever the attempt threw.\n * @param method - The upper-cased HTTP method of the request.\n * @returns Whether the client should try again.\n */\nfunction isRetriableFailure(error: unknown, method: string): boolean {\n if (!IDEMPOTENT_METHODS.has(method)) return false;\n if (!(error instanceof TempestApiError)) return false;\n return isRetriableStatus(error.status);\n}\n\n/**\n * Normalize the `retry` config into options, or `null` when retrying is off.\n *\n * @param config - The `retry` field as the caller wrote it.\n * @returns Retry options to use, or `null` to run a single attempt.\n */\nfunction resolveRetry(config: boolean | RetryOptions | undefined): RetryOptions | null {\n if (!config) return null;\n return config === true ? {} : config;\n}\n\n/** Default milliseconds before a request is abandoned. */\nconst DEFAULT_TIMEOUT = 15_000;\n\n/** Default milliseconds before a `FormData` request is abandoned. */\nconst DEFAULT_UPLOAD_TIMEOUT = 300_000;\n\nfunction isFormData(body: unknown): body is FormData {\n return typeof FormData !== \"undefined\" && body instanceof FormData;\n}\n\n/**\n * Milliseconds since a `performance.now()` reading, rounded.\n *\n * `performance.now()` rather than `Date.now()` because this measures a\n * duration: the wall clock can step sideways mid-request (an NTP correction, a\n * VM resuming, the user changing the clock) and turn a 40 ms call into a\n * negative number. The monotonic clock cannot.\n *\n * @param startedAt - The reading taken before the work started.\n * @returns Whole milliseconds elapsed.\n */\nfunction elapsedMs(startedAt: number): number {\n return Math.round(performance.now() - startedAt);\n}\n\nasync function parseError(response: Response, sentRequestId?: string): Promise<TempestApiError> {\n let body: unknown;\n try {\n body = await response.clone().json();\n } catch {\n try {\n body = await response.text();\n } catch {\n body = null;\n }\n }\n return new TempestApiError(\n buildApiError(response.status, body, response.headers, sentRequestId),\n );\n}\n\n/**\n * Create a typed HTTP client backed by `fetch`.\n *\n * Handles JSON serialization, query params, bearer auth via `getToken`, uploads\n * via `FormData`, and throws a typed `ApiError` on any non-2xx response.\n *\n * **Expired sessions.** A `401` with `refresh` configured awaits the refresh and\n * replays the request once. `onUnauthorized` fires whenever that path ends\n * unauthorized anyway — the refresh threw, or the replay came back `401` — which\n * is the signal to clear the session. Without `refresh`, the first `401` calls\n * it directly.\n *\n * **Binary bodies** have their own methods — `blob()` and `arrayBuffer()` — which\n * run the same pipeline and skip the parse. `request()` no longer decodes an\n * unknown `Content-Type` as text either: it returns a `Blob` and says so once in\n * a development build, because reading `image/jpeg` as UTF-8 destroyed the bytes\n * on the way in.\n *\n * **The bearer token is scoped to `baseURL`'s origin** since 0.66.0. A path may\n * be an absolute URL, which overrides the base entirely, so without a scope the\n * destination of a credentialed request could come from a value read off the\n * network. A request to another origin still goes out; it goes without the\n * header, and a development build says so once. Declare the exceptions in\n * {@link ApiClientConfig.trustedOrigins}.\n *\n * **CSRF** is off unless you set `csrf`. With it, every unsafe request (anything\n * but `GET`/`HEAD`/`OPTIONS`/`TRACE`) echoes the `csrf_token` cookie in an\n * `X-CSRF-Token` header — the double-submit contract of `tempest-fastapi-sdk`'s\n * `CSRFMiddleware` — scoped to the same origins as the bearer token and sent on\n * `skipAuth` requests too. See {@link ApiClientConfig.csrf}.\n *\n * **Retries** are off unless you set `retry`. See {@link ApiClientConfig.retry}\n * for the built-in policy; it never replays a write. A single call overrides it\n * with {@link RequestOptions.retry}, and opts out of auth entirely with\n * {@link RequestOptions.skipAuth}.\n *\n * **Logging** is off unless you pass a `logger`. With one, every finished attempt\n * writes a line — `debug` under 400, `warn` from 400 up, plus a `warn` when\n * `onUnauthorized` fires — carrying `requestId`, `status` and elapsed `ms`, and\n * never a body, header or query string. The level and the destination belong to\n * the logger, not to a boolean here.\n *\n * @example\n * const api = createApiClient({\n * baseURL: import.meta.env.VITE_API_URL,\n * getToken: () => useAuthStore.getState().token,\n * refresh,\n * onUnauthorized: () => useAuthStore.getState().logout(),\n * logger: createLogger({ level: import.meta.env.DEV ? \"debug\" : \"warn\" }).child(\"http\"),\n * retry: true,\n * });\n *\n * @param config - Base URL plus the optional auth, retry and fetch hooks.\n * @returns A client with `request`/`get`/`post`/`put`/`patch`/`delete`/`blob`/\n * `arrayBuffer`/`upload`.\n */\nexport function createApiClient(config: ApiClientConfig): ApiClient {\n const fetcher = config.fetcher ?? globalThis.fetch.bind(globalThis);\n\n /**\n * The `Authorization` header for a request, or nothing.\n *\n * Takes the resolved URL rather than reading `config.baseURL`, because an\n * absolute path overrides the base entirely (see `buildApiUrl`) and the\n * credential is scoped to an origin, not to a client.\n *\n * @param url - The absolute URL the request is going to.\n * @param skipAuth - Whether the caller opted this request out of auth.\n * @returns The header, or an empty object.\n */\n function authHeaders(url: string, skipAuth: boolean): Record<string, string> {\n if (skipAuth) return {};\n const token = config.getToken?.();\n if (!token) return {};\n if (!isTrustedCredentialTarget(url, config.baseURL, config.trustedOrigins)) {\n reportSuppressedCredential(url, config.baseURL);\n return {};\n }\n return { Authorization: `Bearer ${token}` };\n }\n\n async function rawRequest(\n path: string,\n options: RequestOptions,\n requestId: string | undefined,\n skipAuth: boolean,\n ): Promise<Response> {\n const { body, params, headers, signal, timeout, ...rest } = options;\n const isForm = isFormData(body);\n const configured = isForm ? config.uploadTimeout : config.timeout;\n const fallback = isForm ? DEFAULT_UPLOAD_TIMEOUT : DEFAULT_TIMEOUT;\n const limit =\n timeout !== undefined ? timeout : configured !== undefined ? configured : fallback;\n\n const url = buildApiUrl(config.baseURL, path, { prefix: config.prefix, params });\n\n const callerHeaders = headers as Record<string, string> | undefined;\n const finalHeaders: Record<string, string> = {\n ...(isForm ? {} : { \"Content-Type\": \"application/json\" }),\n ...(requestId ? { \"X-Request-ID\": requestId } : {}),\n ...config.headers,\n ...authHeaders(url, skipAuth),\n ...csrfHeaders({\n method: rest.method ?? \"GET\",\n url,\n reference: config.baseURL,\n csrf: config.csrf,\n trustedOrigins: config.trustedOrigins,\n headers: { ...config.headers, ...callerHeaders },\n }),\n ...callerHeaders,\n };\n\n const timed = withTimeout(signal, limit);\n const init: RequestInit = {\n ...rest,\n signal: timed.signal,\n headers: finalHeaders,\n credentials: config.withCredentials ? \"include\" : rest.credentials,\n body:\n body === undefined || body === null\n ? undefined\n : isForm\n ? (body as FormData)\n : JSON.stringify(body),\n };\n\n try {\n return await fetcher(url, init);\n } catch (cause) {\n if (timed.timedOut()) {\n throw new TempestApiError({\n status: 0,\n detail: `A requisição excedeu ${limit}ms e foi abandonada.`,\n });\n }\n throw cause;\n } finally {\n timed.dispose();\n }\n }\n\n async function send(\n path: string,\n options: RequestOptions,\n requestId: string,\n method: string,\n skipAuth: boolean,\n ): Promise<Response> {\n const log = config.logger;\n if (!log) return rawRequest(path, options, requestId, skipAuth);\n\n const startedAt = performance.now();\n try {\n const response = await rawRequest(path, options, requestId, skipAuth);\n const entry = { requestId, status: response.status, ms: elapsedMs(startedAt) };\n const line = `${method} ${path} → ${response.status}`;\n if (response.status >= 400) log.warn(line, entry);\n else log.debug(line, entry);\n return response;\n } catch (error) {\n log.warn(`${method} ${path} → no response`, {\n requestId,\n ms: elapsedMs(startedAt),\n error,\n });\n throw error;\n }\n }\n\n /**\n * Call `onUnauthorized`, keeping the request's own error unless the hook\n * says the attempt belongs to an ended session.\n *\n * @param response - The `401` that ended the attempt.\n * @param requestId - Correlation id for the log.\n * @param context - The credential and session the attempt left with.\n * @throws {SessionSupersededError} When `onUnauthorized` threw it.\n */\n async function notifyUnauthorized(\n response: Response,\n requestId: string,\n context: RequestAuthContext,\n ): Promise<void> {\n if (!config.onUnauthorized) return;\n config.logger?.warn(`unauthorized — calling onUnauthorized`, {\n requestId,\n status: response.status,\n });\n try {\n await config.onUnauthorized(response, context);\n } catch (error) {\n if (error instanceof SessionSupersededError) throw error;\n config.logger?.warn(`onUnauthorized threw — keeping the original response error`, {\n requestId,\n status: response.status,\n error,\n });\n }\n }\n\n /**\n * The credential and session an attempt leaves with, read just before it is\n * sent.\n *\n * @param skipAuth - Whether the attempt goes out without credentials.\n * @returns The context handed to `refresh` and `onUnauthorized`.\n */\n function authContext(skipAuth: boolean): RequestAuthContext {\n return {\n token: skipAuth ? null : (config.getToken?.() ?? null),\n epoch: config.getSessionEpoch?.() ?? null,\n };\n }\n\n async function attempt<T>(\n path: string,\n options: RequestOptions,\n decode: ResponseDecoder<T>,\n ): Promise<T> {\n const { skipAuth = false, skipAuthRetry = false, ...init } = options;\n const requestId = config.requestId ? config.requestId() : randomId();\n const method = (init.method ?? \"GET\").toUpperCase();\n const sent = authContext(skipAuth);\n let response = await send(path, init, requestId, method, skipAuth);\n\n if (response.status === 401 && !skipAuth && !skipAuthRetry) {\n if (config.refresh) {\n let replayed: RequestAuthContext;\n try {\n await config.refresh(sent);\n replayed = authContext(skipAuth);\n response = await send(path, init, requestId, method, skipAuth);\n } catch (error) {\n if (error instanceof SessionSupersededError) throw error;\n await notifyUnauthorized(response, requestId, sent);\n throw await parseError(response, requestId);\n }\n if (response.status === 401) {\n await notifyUnauthorized(response, requestId, replayed);\n }\n } else {\n await notifyUnauthorized(response, requestId, sent);\n }\n }\n\n if (!response.ok) {\n throw await parseError(response, requestId);\n }\n\n return decode(response);\n }\n\n async function run<T>(\n path: string,\n options: RequestOptions,\n decode: ResponseDecoder<T>,\n ): Promise<T> {\n const { retry: perRequest, ...rest } = options;\n const retryOptions = resolveRetry(perRequest !== undefined ? perRequest : config.retry);\n if (!retryOptions) return attempt<T>(path, rest, decode);\n\n const method = (rest.method ?? \"GET\").toUpperCase();\n return retryWithBackoff(() => attempt<T>(path, rest, decode), {\n ...retryOptions,\n shouldRetry:\n retryOptions.shouldRetry ?? ((error: unknown) => isRetriableFailure(error, method)),\n });\n }\n\n async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {\n return run<T>(path, options, decodeByContentType);\n }\n\n async function upload<T>(\n path: string,\n formData: FormData,\n method: \"POST\" | \"PUT\" | \"PATCH\" = \"POST\",\n options?: Omit<RequestOptions, \"body\" | \"method\">,\n ): Promise<T> {\n return request<T>(path, { ...options, method, body: formData });\n }\n\n return {\n request,\n get: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"GET\" }),\n post: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"POST\" }),\n put: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"PUT\" }),\n patch: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"PATCH\" }),\n delete: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"DELETE\" }),\n blob: (path: string, options?: RequestOptions) =>\n run<Blob>(path, { ...options }, (response) => response.blob()),\n arrayBuffer: (path: string, options?: RequestOptions) =>\n run<ArrayBuffer>(path, { ...options }, (response) => response.arrayBuffer()),\n upload,\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"api-client.cjs","names":["TempestApiError","isRetriableStatus","buildApiError","isTrustedCredentialTarget","buildApiUrl","csrfHeaders","withTimeout","SessionSupersededError","randomId","retryWithBackoff","decodeByContentType","require_ids","require_build_url","require_credential_scope","require_csrf","require_decode_response","require_errors","require_retry","require_timeout","IDEMPOTENT_METHODS","isRetriableFailure","method","error","resolveRetry","config","DEFAULT_TIMEOUT","DEFAULT_UPLOAD_TIMEOUT","isFormData","body","elapsedMs","startedAt","parseError","response","sentRequestId","createApiClient","fetcher","authHeaders","skipAuth","token","url","rawRequest","rest","options","isForm","configured","limit","timeout","path","callerHeaders","headers","finalHeaders","requestId","timed","signal","init","cause","send","log","entry","line","notifyUnauthorized","context","authContext","attempt","sent","skipAuthRetry","replayed","decode","run","perRequest","retryOptions","request","upload","formData"],"sources":["../../src/http/api-client.ts"],"sourcesContent":["/**\n * @tempest-limits function-lines — createApiClient is over the limit and every line\n * is a request-lifecycle concern the client cannot delegate: base URL joining, the\n * auth header, the 401 refresh-and-replay, the opt-in retry wrapper and the response\n * parsing that turns a failure into a typed error.\n */\nimport { randomId } from \"../utils\";\nimport { buildApiUrl } from \"./build-url\";\nimport { isTrustedCredentialTarget, reportSuppressedCredential } from \"./credential-scope\";\nimport { csrfHeaders } from \"./csrf\";\nimport { decodeByContentType } from \"./decode-response\";\nimport type { ResponseDecoder } from \"./decode-response\";\nimport {\n buildApiError,\n SessionSupersededError,\n TempestApiError,\n isRetriableStatus,\n} from \"./errors\";\nimport { retry as retryWithBackoff } from \"./retry\";\nimport type { RetryOptions } from \"./retry\";\nimport { withTimeout } from \"./timeout\";\nimport type { ApiClient, ApiClientConfig, RequestAuthContext, RequestOptions } from \"./types\";\n\n/**\n * Methods the built-in retry policy will replay.\n *\n * `PUT` and `DELETE` are idempotent on paper but stay out: a backend that logs,\n * bills, or fires a webhook per call still sees two, so replaying them is a\n * decision the caller makes through `shouldRetry`, not a default.\n */\nconst IDEMPOTENT_METHODS: ReadonlySet<string> = new Set([\"GET\", \"HEAD\", \"OPTIONS\"]);\n\n/**\n * Sub-500 statuses worth a second attempt: a network failure (status `0`), a\n * request timeout, a too-early replay, and a rate limit — which usually carries\n * the `Retry-After` the backoff already honours.\n */\n\n/**\n * The built-in retry policy, used when `retry` is `true` or is options carrying\n * no `shouldRetry` of their own.\n *\n * Conservative on purpose. Replaying a write can duplicate it, and replaying a\n * `400` or a `403` cannot fix a bad payload or a permission the caller does not\n * have — it only spends the user's time before showing the same error.\n *\n * @param error - Whatever the attempt threw.\n * @param method - The upper-cased HTTP method of the request.\n * @returns Whether the client should try again.\n */\nfunction isRetriableFailure(error: unknown, method: string): boolean {\n if (!IDEMPOTENT_METHODS.has(method)) return false;\n if (!(error instanceof TempestApiError)) return false;\n return isRetriableStatus(error.status);\n}\n\n/**\n * Normalize the `retry` config into options, or `null` when retrying is off.\n *\n * @param config - The `retry` field as the caller wrote it.\n * @returns Retry options to use, or `null` to run a single attempt.\n */\nfunction resolveRetry(config: boolean | RetryOptions | undefined): RetryOptions | null {\n if (!config) return null;\n return config === true ? {} : config;\n}\n\n/** Default milliseconds before a request is abandoned. */\nconst DEFAULT_TIMEOUT = 15_000;\n\n/** Default milliseconds before a `FormData` request is abandoned. */\nconst DEFAULT_UPLOAD_TIMEOUT = 300_000;\n\nfunction isFormData(body: unknown): body is FormData {\n return typeof FormData !== \"undefined\" && body instanceof FormData;\n}\n\n/**\n * Milliseconds since a `performance.now()` reading, rounded.\n *\n * `performance.now()` rather than `Date.now()` because this measures a\n * duration: the wall clock can step sideways mid-request (an NTP correction, a\n * VM resuming, the user changing the clock) and turn a 40 ms call into a\n * negative number. The monotonic clock cannot.\n *\n * @param startedAt - The reading taken before the work started.\n * @returns Whole milliseconds elapsed.\n */\nfunction elapsedMs(startedAt: number): number {\n return Math.round(performance.now() - startedAt);\n}\n\nasync function parseError(response: Response, sentRequestId?: string): Promise<TempestApiError> {\n let body: unknown;\n try {\n body = await response.clone().json();\n } catch {\n try {\n body = await response.text();\n } catch {\n body = null;\n }\n }\n return new TempestApiError(\n buildApiError(response.status, body, response.headers, sentRequestId),\n );\n}\n\n/**\n * Create a typed HTTP client backed by `fetch`.\n *\n * Handles JSON serialization, query params, bearer auth via `getToken`, uploads\n * via `FormData`, and throws a typed `ApiError` on any non-2xx response.\n *\n * **Expired sessions.** A `401` with `refresh` configured awaits the refresh and\n * replays the request once. `onUnauthorized` fires whenever that path ends\n * unauthorized anyway — the refresh threw, or the replay came back `401` — which\n * is the signal to clear the session. Without `refresh`, the first `401` calls\n * it directly.\n *\n * **Binary bodies** have their own methods — `blob()` and `arrayBuffer()` — which\n * run the same pipeline and skip the parse. `request()` no longer decodes an\n * unknown `Content-Type` as text either: it returns a `Blob` and says so once in\n * a development build, because reading `image/jpeg` as UTF-8 destroyed the bytes\n * on the way in.\n *\n * **The bearer token is scoped to `baseURL`'s origin** since 0.66.0. A path may\n * be an absolute URL, which overrides the base entirely, so without a scope the\n * destination of a credentialed request could come from a value read off the\n * network. A request to another origin still goes out; it goes without the\n * header, and a development build says so once. Declare the exceptions in\n * {@link ApiClientConfig.trustedOrigins}.\n *\n * **CSRF** is off unless you set `csrf`. With it, every unsafe request (anything\n * but `GET`/`HEAD`/`OPTIONS`/`TRACE`) echoes the `csrf_token` cookie in an\n * `X-CSRF-Token` header — the double-submit contract of `tempest-fastapi-sdk`'s\n * `CSRFMiddleware` — scoped to the same origins as the bearer token and sent on\n * `skipAuth` requests too. See {@link ApiClientConfig.csrf}.\n *\n * **Retries** are off unless you set `retry`. See {@link ApiClientConfig.retry}\n * for the built-in policy; it never replays a write. A single call overrides it\n * with {@link RequestOptions.retry}, and opts out of auth entirely with\n * {@link RequestOptions.skipAuth}.\n *\n * **Logging** is off unless you pass a `logger`. With one, every finished attempt\n * writes a line — `debug` under 400, `warn` from 400 up, plus a `warn` when\n * `onUnauthorized` fires — carrying `requestId`, `status` and elapsed `ms`, and\n * never a body, header or query string. The level and the destination belong to\n * the logger, not to a boolean here.\n *\n * @example\n * const api = createApiClient({\n * baseURL: import.meta.env.VITE_API_URL,\n * getToken: () => useAuthStore.getState().token,\n * refresh,\n * onUnauthorized: () => useAuthStore.getState().logout(),\n * logger: createLogger({ level: import.meta.env.DEV ? \"debug\" : \"warn\" }).child(\"http\"),\n * retry: true,\n * });\n *\n * @param config - Base URL plus the optional auth, retry and fetch hooks.\n * @returns A client with `request`/`get`/`post`/`put`/`patch`/`delete`/`blob`/\n * `arrayBuffer`/`upload`.\n */\nexport function createApiClient(config: ApiClientConfig): ApiClient {\n const fetcher = config.fetcher ?? globalThis.fetch.bind(globalThis);\n\n /**\n * The `Authorization` header for a request, or nothing.\n *\n * Takes the resolved URL rather than reading `config.baseURL`, because an\n * absolute path overrides the base entirely (see `buildApiUrl`) and the\n * credential is scoped to an origin, not to a client.\n *\n * @param url - The absolute URL the request is going to.\n * @param skipAuth - Whether the caller opted this request out of auth.\n * @returns The header, or an empty object.\n */\n function authHeaders(url: string, skipAuth: boolean): Record<string, string> {\n if (skipAuth) return {};\n const token = config.getToken?.();\n if (!token) return {};\n if (!isTrustedCredentialTarget(url, config.baseURL, config.trustedOrigins)) {\n reportSuppressedCredential(url, config.baseURL);\n return {};\n }\n return { Authorization: `Bearer ${token}` };\n }\n\n async function rawRequest(\n path: string,\n options: RequestOptions,\n requestId: string | undefined,\n skipAuth: boolean,\n ): Promise<Response> {\n const { body, params, headers, signal, timeout, ...rest } = options;\n const isForm = isFormData(body);\n const configured = isForm ? config.uploadTimeout : config.timeout;\n const fallback = isForm ? DEFAULT_UPLOAD_TIMEOUT : DEFAULT_TIMEOUT;\n const limit =\n timeout !== undefined ? timeout : configured !== undefined ? configured : fallback;\n\n const url = buildApiUrl(config.baseURL, path, { prefix: config.prefix, params });\n\n const callerHeaders = headers as Record<string, string> | undefined;\n const finalHeaders: Record<string, string> = {\n ...(isForm ? {} : { \"Content-Type\": \"application/json\" }),\n ...(requestId ? { \"X-Request-ID\": requestId } : {}),\n ...config.headers,\n ...authHeaders(url, skipAuth),\n ...csrfHeaders({\n method: rest.method ?? \"GET\",\n url,\n reference: config.baseURL,\n csrf: config.csrf,\n trustedOrigins: config.trustedOrigins,\n headers: { ...config.headers, ...callerHeaders },\n }),\n ...callerHeaders,\n };\n\n const timed = withTimeout(signal, limit);\n const init: RequestInit = {\n ...rest,\n signal: timed.signal,\n headers: finalHeaders,\n credentials: config.withCredentials ? \"include\" : rest.credentials,\n body:\n body === undefined || body === null\n ? undefined\n : isForm\n ? (body as FormData)\n : JSON.stringify(body),\n };\n\n try {\n return await fetcher(url, init);\n } catch (cause) {\n if (timed.timedOut()) {\n throw new TempestApiError({\n status: 0,\n detail: `A requisição excedeu ${limit}ms e foi abandonada.`,\n });\n }\n throw cause;\n } finally {\n timed.dispose();\n }\n }\n\n async function send(\n path: string,\n options: RequestOptions,\n requestId: string,\n method: string,\n skipAuth: boolean,\n ): Promise<Response> {\n const log = config.logger;\n if (!log) return rawRequest(path, options, requestId, skipAuth);\n\n const startedAt = performance.now();\n try {\n const response = await rawRequest(path, options, requestId, skipAuth);\n const entry = { requestId, status: response.status, ms: elapsedMs(startedAt) };\n const line = `${method} ${path} → ${response.status}`;\n if (response.status >= 400) log.warn(line, entry);\n else log.debug(line, entry);\n return response;\n } catch (error) {\n log.warn(`${method} ${path} → no response`, {\n requestId,\n ms: elapsedMs(startedAt),\n error,\n });\n throw error;\n }\n }\n\n /**\n * Call `onUnauthorized`, keeping the request's own error unless the hook\n * says the attempt belongs to an ended session.\n *\n * @param response - The `401` that ended the attempt.\n * @param requestId - Correlation id for the log.\n * @param context - The credential and session the attempt left with.\n * @throws {SessionSupersededError} When `onUnauthorized` threw it.\n */\n async function notifyUnauthorized(\n response: Response,\n requestId: string,\n context: RequestAuthContext,\n ): Promise<void> {\n if (!config.onUnauthorized) return;\n config.logger?.warn(`unauthorized — calling onUnauthorized`, {\n requestId,\n status: response.status,\n });\n try {\n await config.onUnauthorized(response, context);\n } catch (error) {\n if (error instanceof SessionSupersededError) throw error;\n config.logger?.warn(`onUnauthorized threw — keeping the original response error`, {\n requestId,\n status: response.status,\n error,\n });\n }\n }\n\n /**\n * The credential and session an attempt leaves with, read just before it is\n * sent.\n *\n * @param skipAuth - Whether the attempt goes out without credentials.\n * @returns The context handed to `refresh` and `onUnauthorized`.\n */\n function authContext(skipAuth: boolean): RequestAuthContext {\n return {\n token: skipAuth ? null : (config.getToken?.() ?? null),\n epoch: config.getSessionEpoch?.() ?? null,\n };\n }\n\n async function attempt<T>(\n path: string,\n options: RequestOptions,\n decode: ResponseDecoder<T>,\n ): Promise<T> {\n const { skipAuth = false, skipAuthRetry = false, ...init } = options;\n const requestId = config.requestId ? config.requestId() : randomId();\n const method = (init.method ?? \"GET\").toUpperCase();\n const sent = authContext(skipAuth);\n let response = await send(path, init, requestId, method, skipAuth);\n\n if (response.status === 401 && !skipAuth && !skipAuthRetry) {\n if (config.refresh) {\n let replayed: RequestAuthContext;\n try {\n await config.refresh(sent);\n replayed = authContext(skipAuth);\n response = await send(path, init, requestId, method, skipAuth);\n } catch (error) {\n if (error instanceof SessionSupersededError) throw error;\n await notifyUnauthorized(response, requestId, sent);\n throw await parseError(response, requestId);\n }\n if (response.status === 401) {\n await notifyUnauthorized(response, requestId, replayed);\n }\n } else {\n await notifyUnauthorized(response, requestId, sent);\n }\n }\n\n if (!response.ok) {\n throw await parseError(response, requestId);\n }\n\n return decode(response);\n }\n\n async function run<T>(\n path: string,\n options: RequestOptions,\n decode: ResponseDecoder<T>,\n ): Promise<T> {\n const { retry: perRequest, ...rest } = options;\n const retryOptions = resolveRetry(perRequest !== undefined ? perRequest : config.retry);\n if (!retryOptions) return attempt<T>(path, rest, decode);\n\n const method = (rest.method ?? \"GET\").toUpperCase();\n return retryWithBackoff(() => attempt<T>(path, rest, decode), {\n ...retryOptions,\n shouldRetry:\n retryOptions.shouldRetry ?? ((error: unknown) => isRetriableFailure(error, method)),\n });\n }\n\n async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {\n return run<T>(path, options, decodeByContentType);\n }\n\n async function upload<T>(\n path: string,\n formData: FormData,\n method: \"POST\" | \"PUT\" | \"PATCH\" = \"POST\",\n options?: Omit<RequestOptions, \"body\" | \"method\">,\n ): Promise<T> {\n return request<T>(path, { ...options, method, body: formData });\n }\n\n return {\n request,\n get: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"GET\" }),\n post: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"POST\" }),\n put: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"PUT\" }),\n patch: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"PATCH\" }),\n delete: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"DELETE\" }),\n blob: (path: string, options?: RequestOptions) =>\n run<Blob>(path, { ...options }, (response) => response.blob()),\n arrayBuffer: (path: string, options?: RequestOptions) =>\n run<ArrayBuffer>(path, { ...options }, (response) => response.arrayBuffer()),\n upload,\n url: (path: string, params?: RequestOptions[\"params\"]) =>\n buildApiUrl(config.baseURL, path, { prefix: config.prefix, params }),\n };\n}\n"],"mappings":"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"}
|
package/dist/http/api-client.js
CHANGED
|
@@ -186,7 +186,11 @@ function b(c) {
|
|
|
186
186
|
}),
|
|
187
187
|
blob: (e, t) => T(e, { ...t }, (e) => e.blob()),
|
|
188
188
|
arrayBuffer: (e, t) => T(e, { ...t }, (e) => e.arrayBuffer()),
|
|
189
|
-
upload: D
|
|
189
|
+
upload: D,
|
|
190
|
+
url: (e, n) => t(c.baseURL, e, {
|
|
191
|
+
prefix: c.prefix,
|
|
192
|
+
params: n
|
|
193
|
+
})
|
|
190
194
|
};
|
|
191
195
|
}
|
|
192
196
|
//#endregion
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api-client.js","names":["retryWithBackoff","IDEMPOTENT_METHODS","isRetriableFailure","method","error","TempestApiError","isRetriableStatus","resolveRetry","config","DEFAULT_TIMEOUT","DEFAULT_UPLOAD_TIMEOUT","isFormData","body","elapsedMs","startedAt","parseError","response","buildApiError","sentRequestId","createApiClient","fetcher","authHeaders","skipAuth","token","isTrustedCredentialTarget","url","reportSuppressedCredential","rawRequest","rest","options","isForm","configured","limit","timeout","buildApiUrl","path","callerHeaders","headers","finalHeaders","requestId","csrfHeaders","timed","withTimeout","signal","init","cause","send","log","entry","line","notifyUnauthorized","context","SessionSupersededError","authContext","attempt","randomId","sent","skipAuthRetry","replayed","decode","run","perRequest","retryOptions","retry","request","decodeByContentType","upload","formData"],"sources":["../../src/http/api-client.ts"],"sourcesContent":["/**\n * @tempest-limits function-lines — createApiClient is over the limit and every line\n * is a request-lifecycle concern the client cannot delegate: base URL joining, the\n * auth header, the 401 refresh-and-replay, the opt-in retry wrapper and the response\n * parsing that turns a failure into a typed error.\n */\nimport { randomId } from \"../utils\";\nimport { buildApiUrl } from \"./build-url\";\nimport { isTrustedCredentialTarget, reportSuppressedCredential } from \"./credential-scope\";\nimport { csrfHeaders } from \"./csrf\";\nimport { decodeByContentType } from \"./decode-response\";\nimport type { ResponseDecoder } from \"./decode-response\";\nimport {\n buildApiError,\n SessionSupersededError,\n TempestApiError,\n isRetriableStatus,\n} from \"./errors\";\nimport { retry as retryWithBackoff } from \"./retry\";\nimport type { RetryOptions } from \"./retry\";\nimport { withTimeout } from \"./timeout\";\nimport type { ApiClient, ApiClientConfig, RequestAuthContext, RequestOptions } from \"./types\";\n\n/**\n * Methods the built-in retry policy will replay.\n *\n * `PUT` and `DELETE` are idempotent on paper but stay out: a backend that logs,\n * bills, or fires a webhook per call still sees two, so replaying them is a\n * decision the caller makes through `shouldRetry`, not a default.\n */\nconst IDEMPOTENT_METHODS: ReadonlySet<string> = new Set([\"GET\", \"HEAD\", \"OPTIONS\"]);\n\n/**\n * Sub-500 statuses worth a second attempt: a network failure (status `0`), a\n * request timeout, a too-early replay, and a rate limit — which usually carries\n * the `Retry-After` the backoff already honours.\n */\n\n/**\n * The built-in retry policy, used when `retry` is `true` or is options carrying\n * no `shouldRetry` of their own.\n *\n * Conservative on purpose. Replaying a write can duplicate it, and replaying a\n * `400` or a `403` cannot fix a bad payload or a permission the caller does not\n * have — it only spends the user's time before showing the same error.\n *\n * @param error - Whatever the attempt threw.\n * @param method - The upper-cased HTTP method of the request.\n * @returns Whether the client should try again.\n */\nfunction isRetriableFailure(error: unknown, method: string): boolean {\n if (!IDEMPOTENT_METHODS.has(method)) return false;\n if (!(error instanceof TempestApiError)) return false;\n return isRetriableStatus(error.status);\n}\n\n/**\n * Normalize the `retry` config into options, or `null` when retrying is off.\n *\n * @param config - The `retry` field as the caller wrote it.\n * @returns Retry options to use, or `null` to run a single attempt.\n */\nfunction resolveRetry(config: boolean | RetryOptions | undefined): RetryOptions | null {\n if (!config) return null;\n return config === true ? {} : config;\n}\n\n/** Default milliseconds before a request is abandoned. */\nconst DEFAULT_TIMEOUT = 15_000;\n\n/** Default milliseconds before a `FormData` request is abandoned. */\nconst DEFAULT_UPLOAD_TIMEOUT = 300_000;\n\nfunction isFormData(body: unknown): body is FormData {\n return typeof FormData !== \"undefined\" && body instanceof FormData;\n}\n\n/**\n * Milliseconds since a `performance.now()` reading, rounded.\n *\n * `performance.now()` rather than `Date.now()` because this measures a\n * duration: the wall clock can step sideways mid-request (an NTP correction, a\n * VM resuming, the user changing the clock) and turn a 40 ms call into a\n * negative number. The monotonic clock cannot.\n *\n * @param startedAt - The reading taken before the work started.\n * @returns Whole milliseconds elapsed.\n */\nfunction elapsedMs(startedAt: number): number {\n return Math.round(performance.now() - startedAt);\n}\n\nasync function parseError(response: Response, sentRequestId?: string): Promise<TempestApiError> {\n let body: unknown;\n try {\n body = await response.clone().json();\n } catch {\n try {\n body = await response.text();\n } catch {\n body = null;\n }\n }\n return new TempestApiError(\n buildApiError(response.status, body, response.headers, sentRequestId),\n );\n}\n\n/**\n * Create a typed HTTP client backed by `fetch`.\n *\n * Handles JSON serialization, query params, bearer auth via `getToken`, uploads\n * via `FormData`, and throws a typed `ApiError` on any non-2xx response.\n *\n * **Expired sessions.** A `401` with `refresh` configured awaits the refresh and\n * replays the request once. `onUnauthorized` fires whenever that path ends\n * unauthorized anyway — the refresh threw, or the replay came back `401` — which\n * is the signal to clear the session. Without `refresh`, the first `401` calls\n * it directly.\n *\n * **Binary bodies** have their own methods — `blob()` and `arrayBuffer()` — which\n * run the same pipeline and skip the parse. `request()` no longer decodes an\n * unknown `Content-Type` as text either: it returns a `Blob` and says so once in\n * a development build, because reading `image/jpeg` as UTF-8 destroyed the bytes\n * on the way in.\n *\n * **The bearer token is scoped to `baseURL`'s origin** since 0.66.0. A path may\n * be an absolute URL, which overrides the base entirely, so without a scope the\n * destination of a credentialed request could come from a value read off the\n * network. A request to another origin still goes out; it goes without the\n * header, and a development build says so once. Declare the exceptions in\n * {@link ApiClientConfig.trustedOrigins}.\n *\n * **CSRF** is off unless you set `csrf`. With it, every unsafe request (anything\n * but `GET`/`HEAD`/`OPTIONS`/`TRACE`) echoes the `csrf_token` cookie in an\n * `X-CSRF-Token` header — the double-submit contract of `tempest-fastapi-sdk`'s\n * `CSRFMiddleware` — scoped to the same origins as the bearer token and sent on\n * `skipAuth` requests too. See {@link ApiClientConfig.csrf}.\n *\n * **Retries** are off unless you set `retry`. See {@link ApiClientConfig.retry}\n * for the built-in policy; it never replays a write. A single call overrides it\n * with {@link RequestOptions.retry}, and opts out of auth entirely with\n * {@link RequestOptions.skipAuth}.\n *\n * **Logging** is off unless you pass a `logger`. With one, every finished attempt\n * writes a line — `debug` under 400, `warn` from 400 up, plus a `warn` when\n * `onUnauthorized` fires — carrying `requestId`, `status` and elapsed `ms`, and\n * never a body, header or query string. The level and the destination belong to\n * the logger, not to a boolean here.\n *\n * @example\n * const api = createApiClient({\n * baseURL: import.meta.env.VITE_API_URL,\n * getToken: () => useAuthStore.getState().token,\n * refresh,\n * onUnauthorized: () => useAuthStore.getState().logout(),\n * logger: createLogger({ level: import.meta.env.DEV ? \"debug\" : \"warn\" }).child(\"http\"),\n * retry: true,\n * });\n *\n * @param config - Base URL plus the optional auth, retry and fetch hooks.\n * @returns A client with `request`/`get`/`post`/`put`/`patch`/`delete`/`blob`/\n * `arrayBuffer`/`upload`.\n */\nexport function createApiClient(config: ApiClientConfig): ApiClient {\n const fetcher = config.fetcher ?? globalThis.fetch.bind(globalThis);\n\n /**\n * The `Authorization` header for a request, or nothing.\n *\n * Takes the resolved URL rather than reading `config.baseURL`, because an\n * absolute path overrides the base entirely (see `buildApiUrl`) and the\n * credential is scoped to an origin, not to a client.\n *\n * @param url - The absolute URL the request is going to.\n * @param skipAuth - Whether the caller opted this request out of auth.\n * @returns The header, or an empty object.\n */\n function authHeaders(url: string, skipAuth: boolean): Record<string, string> {\n if (skipAuth) return {};\n const token = config.getToken?.();\n if (!token) return {};\n if (!isTrustedCredentialTarget(url, config.baseURL, config.trustedOrigins)) {\n reportSuppressedCredential(url, config.baseURL);\n return {};\n }\n return { Authorization: `Bearer ${token}` };\n }\n\n async function rawRequest(\n path: string,\n options: RequestOptions,\n requestId: string | undefined,\n skipAuth: boolean,\n ): Promise<Response> {\n const { body, params, headers, signal, timeout, ...rest } = options;\n const isForm = isFormData(body);\n const configured = isForm ? config.uploadTimeout : config.timeout;\n const fallback = isForm ? DEFAULT_UPLOAD_TIMEOUT : DEFAULT_TIMEOUT;\n const limit =\n timeout !== undefined ? timeout : configured !== undefined ? configured : fallback;\n\n const url = buildApiUrl(config.baseURL, path, { prefix: config.prefix, params });\n\n const callerHeaders = headers as Record<string, string> | undefined;\n const finalHeaders: Record<string, string> = {\n ...(isForm ? {} : { \"Content-Type\": \"application/json\" }),\n ...(requestId ? { \"X-Request-ID\": requestId } : {}),\n ...config.headers,\n ...authHeaders(url, skipAuth),\n ...csrfHeaders({\n method: rest.method ?? \"GET\",\n url,\n reference: config.baseURL,\n csrf: config.csrf,\n trustedOrigins: config.trustedOrigins,\n headers: { ...config.headers, ...callerHeaders },\n }),\n ...callerHeaders,\n };\n\n const timed = withTimeout(signal, limit);\n const init: RequestInit = {\n ...rest,\n signal: timed.signal,\n headers: finalHeaders,\n credentials: config.withCredentials ? \"include\" : rest.credentials,\n body:\n body === undefined || body === null\n ? undefined\n : isForm\n ? (body as FormData)\n : JSON.stringify(body),\n };\n\n try {\n return await fetcher(url, init);\n } catch (cause) {\n if (timed.timedOut()) {\n throw new TempestApiError({\n status: 0,\n detail: `A requisição excedeu ${limit}ms e foi abandonada.`,\n });\n }\n throw cause;\n } finally {\n timed.dispose();\n }\n }\n\n async function send(\n path: string,\n options: RequestOptions,\n requestId: string,\n method: string,\n skipAuth: boolean,\n ): Promise<Response> {\n const log = config.logger;\n if (!log) return rawRequest(path, options, requestId, skipAuth);\n\n const startedAt = performance.now();\n try {\n const response = await rawRequest(path, options, requestId, skipAuth);\n const entry = { requestId, status: response.status, ms: elapsedMs(startedAt) };\n const line = `${method} ${path} → ${response.status}`;\n if (response.status >= 400) log.warn(line, entry);\n else log.debug(line, entry);\n return response;\n } catch (error) {\n log.warn(`${method} ${path} → no response`, {\n requestId,\n ms: elapsedMs(startedAt),\n error,\n });\n throw error;\n }\n }\n\n /**\n * Call `onUnauthorized`, keeping the request's own error unless the hook\n * says the attempt belongs to an ended session.\n *\n * @param response - The `401` that ended the attempt.\n * @param requestId - Correlation id for the log.\n * @param context - The credential and session the attempt left with.\n * @throws {SessionSupersededError} When `onUnauthorized` threw it.\n */\n async function notifyUnauthorized(\n response: Response,\n requestId: string,\n context: RequestAuthContext,\n ): Promise<void> {\n if (!config.onUnauthorized) return;\n config.logger?.warn(`unauthorized — calling onUnauthorized`, {\n requestId,\n status: response.status,\n });\n try {\n await config.onUnauthorized(response, context);\n } catch (error) {\n if (error instanceof SessionSupersededError) throw error;\n config.logger?.warn(`onUnauthorized threw — keeping the original response error`, {\n requestId,\n status: response.status,\n error,\n });\n }\n }\n\n /**\n * The credential and session an attempt leaves with, read just before it is\n * sent.\n *\n * @param skipAuth - Whether the attempt goes out without credentials.\n * @returns The context handed to `refresh` and `onUnauthorized`.\n */\n function authContext(skipAuth: boolean): RequestAuthContext {\n return {\n token: skipAuth ? null : (config.getToken?.() ?? null),\n epoch: config.getSessionEpoch?.() ?? null,\n };\n }\n\n async function attempt<T>(\n path: string,\n options: RequestOptions,\n decode: ResponseDecoder<T>,\n ): Promise<T> {\n const { skipAuth = false, skipAuthRetry = false, ...init } = options;\n const requestId = config.requestId ? config.requestId() : randomId();\n const method = (init.method ?? \"GET\").toUpperCase();\n const sent = authContext(skipAuth);\n let response = await send(path, init, requestId, method, skipAuth);\n\n if (response.status === 401 && !skipAuth && !skipAuthRetry) {\n if (config.refresh) {\n let replayed: RequestAuthContext;\n try {\n await config.refresh(sent);\n replayed = authContext(skipAuth);\n response = await send(path, init, requestId, method, skipAuth);\n } catch (error) {\n if (error instanceof SessionSupersededError) throw error;\n await notifyUnauthorized(response, requestId, sent);\n throw await parseError(response, requestId);\n }\n if (response.status === 401) {\n await notifyUnauthorized(response, requestId, replayed);\n }\n } else {\n await notifyUnauthorized(response, requestId, sent);\n }\n }\n\n if (!response.ok) {\n throw await parseError(response, requestId);\n }\n\n return decode(response);\n }\n\n async function run<T>(\n path: string,\n options: RequestOptions,\n decode: ResponseDecoder<T>,\n ): Promise<T> {\n const { retry: perRequest, ...rest } = options;\n const retryOptions = resolveRetry(perRequest !== undefined ? perRequest : config.retry);\n if (!retryOptions) return attempt<T>(path, rest, decode);\n\n const method = (rest.method ?? \"GET\").toUpperCase();\n return retryWithBackoff(() => attempt<T>(path, rest, decode), {\n ...retryOptions,\n shouldRetry:\n retryOptions.shouldRetry ?? ((error: unknown) => isRetriableFailure(error, method)),\n });\n }\n\n async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {\n return run<T>(path, options, decodeByContentType);\n }\n\n async function upload<T>(\n path: string,\n formData: FormData,\n method: \"POST\" | \"PUT\" | \"PATCH\" = \"POST\",\n options?: Omit<RequestOptions, \"body\" | \"method\">,\n ): Promise<T> {\n return request<T>(path, { ...options, method, body: formData });\n }\n\n return {\n request,\n get: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"GET\" }),\n post: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"POST\" }),\n put: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"PUT\" }),\n patch: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"PATCH\" }),\n delete: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"DELETE\" }),\n blob: (path: string, options?: RequestOptions) =>\n run<Blob>(path, { ...options }, (response) => response.blob()),\n arrayBuffer: (path: string, options?: RequestOptions) =>\n run<ArrayBuffer>(path, { ...options }, (response) => response.arrayBuffer()),\n upload,\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"api-client.js","names":["retryWithBackoff","IDEMPOTENT_METHODS","isRetriableFailure","method","error","TempestApiError","isRetriableStatus","resolveRetry","config","DEFAULT_TIMEOUT","DEFAULT_UPLOAD_TIMEOUT","isFormData","body","elapsedMs","startedAt","parseError","response","buildApiError","sentRequestId","createApiClient","fetcher","authHeaders","skipAuth","token","isTrustedCredentialTarget","url","reportSuppressedCredential","rawRequest","rest","options","isForm","configured","limit","timeout","buildApiUrl","path","callerHeaders","headers","finalHeaders","requestId","csrfHeaders","timed","withTimeout","signal","init","cause","send","log","entry","line","notifyUnauthorized","context","SessionSupersededError","authContext","attempt","randomId","sent","skipAuthRetry","replayed","decode","run","perRequest","retryOptions","retry","request","decodeByContentType","upload","formData"],"sources":["../../src/http/api-client.ts"],"sourcesContent":["/**\n * @tempest-limits function-lines — createApiClient is over the limit and every line\n * is a request-lifecycle concern the client cannot delegate: base URL joining, the\n * auth header, the 401 refresh-and-replay, the opt-in retry wrapper and the response\n * parsing that turns a failure into a typed error.\n */\nimport { randomId } from \"../utils\";\nimport { buildApiUrl } from \"./build-url\";\nimport { isTrustedCredentialTarget, reportSuppressedCredential } from \"./credential-scope\";\nimport { csrfHeaders } from \"./csrf\";\nimport { decodeByContentType } from \"./decode-response\";\nimport type { ResponseDecoder } from \"./decode-response\";\nimport {\n buildApiError,\n SessionSupersededError,\n TempestApiError,\n isRetriableStatus,\n} from \"./errors\";\nimport { retry as retryWithBackoff } from \"./retry\";\nimport type { RetryOptions } from \"./retry\";\nimport { withTimeout } from \"./timeout\";\nimport type { ApiClient, ApiClientConfig, RequestAuthContext, RequestOptions } from \"./types\";\n\n/**\n * Methods the built-in retry policy will replay.\n *\n * `PUT` and `DELETE` are idempotent on paper but stay out: a backend that logs,\n * bills, or fires a webhook per call still sees two, so replaying them is a\n * decision the caller makes through `shouldRetry`, not a default.\n */\nconst IDEMPOTENT_METHODS: ReadonlySet<string> = new Set([\"GET\", \"HEAD\", \"OPTIONS\"]);\n\n/**\n * Sub-500 statuses worth a second attempt: a network failure (status `0`), a\n * request timeout, a too-early replay, and a rate limit — which usually carries\n * the `Retry-After` the backoff already honours.\n */\n\n/**\n * The built-in retry policy, used when `retry` is `true` or is options carrying\n * no `shouldRetry` of their own.\n *\n * Conservative on purpose. Replaying a write can duplicate it, and replaying a\n * `400` or a `403` cannot fix a bad payload or a permission the caller does not\n * have — it only spends the user's time before showing the same error.\n *\n * @param error - Whatever the attempt threw.\n * @param method - The upper-cased HTTP method of the request.\n * @returns Whether the client should try again.\n */\nfunction isRetriableFailure(error: unknown, method: string): boolean {\n if (!IDEMPOTENT_METHODS.has(method)) return false;\n if (!(error instanceof TempestApiError)) return false;\n return isRetriableStatus(error.status);\n}\n\n/**\n * Normalize the `retry` config into options, or `null` when retrying is off.\n *\n * @param config - The `retry` field as the caller wrote it.\n * @returns Retry options to use, or `null` to run a single attempt.\n */\nfunction resolveRetry(config: boolean | RetryOptions | undefined): RetryOptions | null {\n if (!config) return null;\n return config === true ? {} : config;\n}\n\n/** Default milliseconds before a request is abandoned. */\nconst DEFAULT_TIMEOUT = 15_000;\n\n/** Default milliseconds before a `FormData` request is abandoned. */\nconst DEFAULT_UPLOAD_TIMEOUT = 300_000;\n\nfunction isFormData(body: unknown): body is FormData {\n return typeof FormData !== \"undefined\" && body instanceof FormData;\n}\n\n/**\n * Milliseconds since a `performance.now()` reading, rounded.\n *\n * `performance.now()` rather than `Date.now()` because this measures a\n * duration: the wall clock can step sideways mid-request (an NTP correction, a\n * VM resuming, the user changing the clock) and turn a 40 ms call into a\n * negative number. The monotonic clock cannot.\n *\n * @param startedAt - The reading taken before the work started.\n * @returns Whole milliseconds elapsed.\n */\nfunction elapsedMs(startedAt: number): number {\n return Math.round(performance.now() - startedAt);\n}\n\nasync function parseError(response: Response, sentRequestId?: string): Promise<TempestApiError> {\n let body: unknown;\n try {\n body = await response.clone().json();\n } catch {\n try {\n body = await response.text();\n } catch {\n body = null;\n }\n }\n return new TempestApiError(\n buildApiError(response.status, body, response.headers, sentRequestId),\n );\n}\n\n/**\n * Create a typed HTTP client backed by `fetch`.\n *\n * Handles JSON serialization, query params, bearer auth via `getToken`, uploads\n * via `FormData`, and throws a typed `ApiError` on any non-2xx response.\n *\n * **Expired sessions.** A `401` with `refresh` configured awaits the refresh and\n * replays the request once. `onUnauthorized` fires whenever that path ends\n * unauthorized anyway — the refresh threw, or the replay came back `401` — which\n * is the signal to clear the session. Without `refresh`, the first `401` calls\n * it directly.\n *\n * **Binary bodies** have their own methods — `blob()` and `arrayBuffer()` — which\n * run the same pipeline and skip the parse. `request()` no longer decodes an\n * unknown `Content-Type` as text either: it returns a `Blob` and says so once in\n * a development build, because reading `image/jpeg` as UTF-8 destroyed the bytes\n * on the way in.\n *\n * **The bearer token is scoped to `baseURL`'s origin** since 0.66.0. A path may\n * be an absolute URL, which overrides the base entirely, so without a scope the\n * destination of a credentialed request could come from a value read off the\n * network. A request to another origin still goes out; it goes without the\n * header, and a development build says so once. Declare the exceptions in\n * {@link ApiClientConfig.trustedOrigins}.\n *\n * **CSRF** is off unless you set `csrf`. With it, every unsafe request (anything\n * but `GET`/`HEAD`/`OPTIONS`/`TRACE`) echoes the `csrf_token` cookie in an\n * `X-CSRF-Token` header — the double-submit contract of `tempest-fastapi-sdk`'s\n * `CSRFMiddleware` — scoped to the same origins as the bearer token and sent on\n * `skipAuth` requests too. See {@link ApiClientConfig.csrf}.\n *\n * **Retries** are off unless you set `retry`. See {@link ApiClientConfig.retry}\n * for the built-in policy; it never replays a write. A single call overrides it\n * with {@link RequestOptions.retry}, and opts out of auth entirely with\n * {@link RequestOptions.skipAuth}.\n *\n * **Logging** is off unless you pass a `logger`. With one, every finished attempt\n * writes a line — `debug` under 400, `warn` from 400 up, plus a `warn` when\n * `onUnauthorized` fires — carrying `requestId`, `status` and elapsed `ms`, and\n * never a body, header or query string. The level and the destination belong to\n * the logger, not to a boolean here.\n *\n * @example\n * const api = createApiClient({\n * baseURL: import.meta.env.VITE_API_URL,\n * getToken: () => useAuthStore.getState().token,\n * refresh,\n * onUnauthorized: () => useAuthStore.getState().logout(),\n * logger: createLogger({ level: import.meta.env.DEV ? \"debug\" : \"warn\" }).child(\"http\"),\n * retry: true,\n * });\n *\n * @param config - Base URL plus the optional auth, retry and fetch hooks.\n * @returns A client with `request`/`get`/`post`/`put`/`patch`/`delete`/`blob`/\n * `arrayBuffer`/`upload`.\n */\nexport function createApiClient(config: ApiClientConfig): ApiClient {\n const fetcher = config.fetcher ?? globalThis.fetch.bind(globalThis);\n\n /**\n * The `Authorization` header for a request, or nothing.\n *\n * Takes the resolved URL rather than reading `config.baseURL`, because an\n * absolute path overrides the base entirely (see `buildApiUrl`) and the\n * credential is scoped to an origin, not to a client.\n *\n * @param url - The absolute URL the request is going to.\n * @param skipAuth - Whether the caller opted this request out of auth.\n * @returns The header, or an empty object.\n */\n function authHeaders(url: string, skipAuth: boolean): Record<string, string> {\n if (skipAuth) return {};\n const token = config.getToken?.();\n if (!token) return {};\n if (!isTrustedCredentialTarget(url, config.baseURL, config.trustedOrigins)) {\n reportSuppressedCredential(url, config.baseURL);\n return {};\n }\n return { Authorization: `Bearer ${token}` };\n }\n\n async function rawRequest(\n path: string,\n options: RequestOptions,\n requestId: string | undefined,\n skipAuth: boolean,\n ): Promise<Response> {\n const { body, params, headers, signal, timeout, ...rest } = options;\n const isForm = isFormData(body);\n const configured = isForm ? config.uploadTimeout : config.timeout;\n const fallback = isForm ? DEFAULT_UPLOAD_TIMEOUT : DEFAULT_TIMEOUT;\n const limit =\n timeout !== undefined ? timeout : configured !== undefined ? configured : fallback;\n\n const url = buildApiUrl(config.baseURL, path, { prefix: config.prefix, params });\n\n const callerHeaders = headers as Record<string, string> | undefined;\n const finalHeaders: Record<string, string> = {\n ...(isForm ? {} : { \"Content-Type\": \"application/json\" }),\n ...(requestId ? { \"X-Request-ID\": requestId } : {}),\n ...config.headers,\n ...authHeaders(url, skipAuth),\n ...csrfHeaders({\n method: rest.method ?? \"GET\",\n url,\n reference: config.baseURL,\n csrf: config.csrf,\n trustedOrigins: config.trustedOrigins,\n headers: { ...config.headers, ...callerHeaders },\n }),\n ...callerHeaders,\n };\n\n const timed = withTimeout(signal, limit);\n const init: RequestInit = {\n ...rest,\n signal: timed.signal,\n headers: finalHeaders,\n credentials: config.withCredentials ? \"include\" : rest.credentials,\n body:\n body === undefined || body === null\n ? undefined\n : isForm\n ? (body as FormData)\n : JSON.stringify(body),\n };\n\n try {\n return await fetcher(url, init);\n } catch (cause) {\n if (timed.timedOut()) {\n throw new TempestApiError({\n status: 0,\n detail: `A requisição excedeu ${limit}ms e foi abandonada.`,\n });\n }\n throw cause;\n } finally {\n timed.dispose();\n }\n }\n\n async function send(\n path: string,\n options: RequestOptions,\n requestId: string,\n method: string,\n skipAuth: boolean,\n ): Promise<Response> {\n const log = config.logger;\n if (!log) return rawRequest(path, options, requestId, skipAuth);\n\n const startedAt = performance.now();\n try {\n const response = await rawRequest(path, options, requestId, skipAuth);\n const entry = { requestId, status: response.status, ms: elapsedMs(startedAt) };\n const line = `${method} ${path} → ${response.status}`;\n if (response.status >= 400) log.warn(line, entry);\n else log.debug(line, entry);\n return response;\n } catch (error) {\n log.warn(`${method} ${path} → no response`, {\n requestId,\n ms: elapsedMs(startedAt),\n error,\n });\n throw error;\n }\n }\n\n /**\n * Call `onUnauthorized`, keeping the request's own error unless the hook\n * says the attempt belongs to an ended session.\n *\n * @param response - The `401` that ended the attempt.\n * @param requestId - Correlation id for the log.\n * @param context - The credential and session the attempt left with.\n * @throws {SessionSupersededError} When `onUnauthorized` threw it.\n */\n async function notifyUnauthorized(\n response: Response,\n requestId: string,\n context: RequestAuthContext,\n ): Promise<void> {\n if (!config.onUnauthorized) return;\n config.logger?.warn(`unauthorized — calling onUnauthorized`, {\n requestId,\n status: response.status,\n });\n try {\n await config.onUnauthorized(response, context);\n } catch (error) {\n if (error instanceof SessionSupersededError) throw error;\n config.logger?.warn(`onUnauthorized threw — keeping the original response error`, {\n requestId,\n status: response.status,\n error,\n });\n }\n }\n\n /**\n * The credential and session an attempt leaves with, read just before it is\n * sent.\n *\n * @param skipAuth - Whether the attempt goes out without credentials.\n * @returns The context handed to `refresh` and `onUnauthorized`.\n */\n function authContext(skipAuth: boolean): RequestAuthContext {\n return {\n token: skipAuth ? null : (config.getToken?.() ?? null),\n epoch: config.getSessionEpoch?.() ?? null,\n };\n }\n\n async function attempt<T>(\n path: string,\n options: RequestOptions,\n decode: ResponseDecoder<T>,\n ): Promise<T> {\n const { skipAuth = false, skipAuthRetry = false, ...init } = options;\n const requestId = config.requestId ? config.requestId() : randomId();\n const method = (init.method ?? \"GET\").toUpperCase();\n const sent = authContext(skipAuth);\n let response = await send(path, init, requestId, method, skipAuth);\n\n if (response.status === 401 && !skipAuth && !skipAuthRetry) {\n if (config.refresh) {\n let replayed: RequestAuthContext;\n try {\n await config.refresh(sent);\n replayed = authContext(skipAuth);\n response = await send(path, init, requestId, method, skipAuth);\n } catch (error) {\n if (error instanceof SessionSupersededError) throw error;\n await notifyUnauthorized(response, requestId, sent);\n throw await parseError(response, requestId);\n }\n if (response.status === 401) {\n await notifyUnauthorized(response, requestId, replayed);\n }\n } else {\n await notifyUnauthorized(response, requestId, sent);\n }\n }\n\n if (!response.ok) {\n throw await parseError(response, requestId);\n }\n\n return decode(response);\n }\n\n async function run<T>(\n path: string,\n options: RequestOptions,\n decode: ResponseDecoder<T>,\n ): Promise<T> {\n const { retry: perRequest, ...rest } = options;\n const retryOptions = resolveRetry(perRequest !== undefined ? perRequest : config.retry);\n if (!retryOptions) return attempt<T>(path, rest, decode);\n\n const method = (rest.method ?? \"GET\").toUpperCase();\n return retryWithBackoff(() => attempt<T>(path, rest, decode), {\n ...retryOptions,\n shouldRetry:\n retryOptions.shouldRetry ?? ((error: unknown) => isRetriableFailure(error, method)),\n });\n }\n\n async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {\n return run<T>(path, options, decodeByContentType);\n }\n\n async function upload<T>(\n path: string,\n formData: FormData,\n method: \"POST\" | \"PUT\" | \"PATCH\" = \"POST\",\n options?: Omit<RequestOptions, \"body\" | \"method\">,\n ): Promise<T> {\n return request<T>(path, { ...options, method, body: formData });\n }\n\n return {\n request,\n get: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"GET\" }),\n post: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"POST\" }),\n put: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"PUT\" }),\n patch: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"PATCH\" }),\n delete: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"DELETE\" }),\n blob: (path: string, options?: RequestOptions) =>\n run<Blob>(path, { ...options }, (response) => response.blob()),\n arrayBuffer: (path: string, options?: RequestOptions) =>\n run<ArrayBuffer>(path, { ...options }, (response) => response.arrayBuffer()),\n upload,\n url: (path: string, params?: RequestOptions[\"params\"]) =>\n buildApiUrl(config.baseURL, path, { prefix: config.prefix, params }),\n };\n}\n"],"mappings":"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"}
|
package/dist/http/build-url.cjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
var e=/^[a-z][a-z\d+\-.]*:/i;function t(e){return e.split(`/`).filter(Boolean)}function n(e,t){return t.length===0||t.length>e.length?!1:t.every((t,n)=>e[n]===t)}function r(e){let t=globalThis.location?.origin;if(!t)throw TypeError(`createApiClient: baseURL "${e}" is relative and there is no location to resolve it against. Pass an absolute URL (https://api.example.com) outside the browser.`);return t}function i(t,n,r={}){let{prefix:i,params:o}=r
|
|
1
|
+
var e=/^[a-z][a-z\d+\-.]*:/i;function t(e){return e.split(`/`).filter(Boolean)}function n(e,t){return t.length===0||t.length>e.length?!1:t.every((t,n)=>e[n]===t)}function r(e){let t=globalThis.location?.origin;if(!t)throw TypeError(`createApiClient: baseURL "${e}" is relative and there is no location to resolve it against. Pass an absolute URL (https://api.example.com) outside the browser.`);return t}function i(t,n,r={}){let{prefix:i,params:o}=r;if(e.test(n)&&!Object.values(o??{}).some(e=>e!=null))return n;let s=e.test(n)?new URL(n):new URL(a(t,n,i));if(o)for(let[e,t]of Object.entries(o))t!=null&&s.searchParams.set(e,String(t));return s.toString()}function a(i,a,o){let s=e.test(i)?new URL(i):new URL(i,r(i)),c=a.indexOf(`?`),l=c===-1?a:a.slice(0,c),u=c===-1?``:a.slice(c),d=[...t(s.pathname),...t(o??``)],f=t(l),p=n(f,d)?f:[...d,...f],m=l.endsWith(`/`)?`/`:``,h=p.length>0?`/${p.join(`/`)}${m}`:`/`;return`${s.origin}${h}${u}`}exports.buildApiUrl=i;
|
|
2
2
|
//# sourceMappingURL=build-url.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"build-url.cjs","names":["ABSOLUTE_URL","segments","path","startsWithSegments","prefix","index","segment","currentOrigin","origin","baseURL","buildApiUrl","options","
|
|
1
|
+
{"version":3,"file":"build-url.cjs","names":["ABSOLUTE_URL","segments","path","startsWithSegments","prefix","index","segment","currentOrigin","origin","baseURL","buildApiUrl","options","params","value","url","resolve","key","base","queryStart","rawPath","query","basePath","requested","joined","trailing","pathname"],"sources":["../../src/http/build-url.ts"],"sourcesContent":["/**\n * Matches a path that already carries its own scheme (`https:`, `blob:`), which\n * makes it an absolute URL rather than something to resolve against the base.\n */\nconst ABSOLUTE_URL = /^[a-z][a-z\\d+\\-.]*:/i;\n\n/** Split a path into its non-empty segments, dropping surrounding slashes. */\nfunction segments(path: string): string[] {\n return path.split(\"/\").filter(Boolean);\n}\n\n/**\n * Whether `path` already starts with every segment of `prefix`.\n *\n * Compared segment by segment, so a `/api` prefix is not considered present in\n * `/api-keys` — a plain `startsWith` would swallow the resource.\n *\n * @param path - Segments of the request path.\n * @param prefix - Segments of the base path.\n * @returns Whether the prefix is already applied.\n */\nfunction startsWithSegments(path: string[], prefix: string[]): boolean {\n if (prefix.length === 0 || prefix.length > path.length) return false;\n return prefix.every((segment, index) => path[index] === segment);\n}\n\n/**\n * Resolve the origin a relative `baseURL` hangs off.\n *\n * A base such as `\"/api\"` is the shape you get behind a dev-server proxy or a\n * reverse proxy that serves app and API from one host. It only means something\n * in a browsing context, so this throws with the config to fix rather than\n * letting `new URL` report `Invalid base URL` from three frames deeper.\n *\n * @param baseURL - The base as the caller wrote it.\n * @returns The origin to resolve against.\n * @throws When there is no `location` to borrow an origin from.\n */\nfunction currentOrigin(baseURL: string): string {\n const origin = globalThis.location?.origin;\n if (!origin) {\n throw new TypeError(\n `createApiClient: baseURL \"${baseURL}\" is relative and there is no location to resolve it against. Pass an absolute URL (https://api.example.com) outside the browser.`,\n );\n }\n return origin;\n}\n\n/** Options accepted by {@link buildApiUrl}. */\nexport interface BuildApiUrlOptions {\n /**\n * Path segment every request is nested under, such as `\"/api\"`. Joined\n * after the path the `baseURL` already carries.\n */\n prefix?: string;\n /** Query params to append. `undefined` and `null` values are skipped. */\n params?: Record<string, string | number | boolean | undefined | null>;\n}\n\n/**\n * Join a base URL, an optional prefix and a request path into an absolute URL.\n *\n * `new URL(path, base)` on its own is wrong for an API client. It follows the\n * URL spec, where a path starting with `/` is absolute against the *origin* and\n * therefore discards whatever path the base carried: a client on\n * `https://api.example.com/api` asked for `/auth/login` reaches\n * `https://api.example.com/auth/login`, and every request 404s with nothing in\n * the config that looks wrong. This function resolves the path against the base\n * *path* instead, so the leading slash is a matter of taste rather than a\n * silent 404, and `baseURL` + `prefix` are interchangeable ways to say the same\n * thing.\n *\n * The prefix is applied at most once: a path that already opens with it — say\n * `\"/api/auth/login\"` under a `\"/api\"` prefix — is left alone, so a codebase\n * migrating to `prefix` can move its call sites one at a time. The check is per\n * segment, so `/api-keys` is not mistaken for an already-prefixed path.\n *\n * A path that is itself an absolute URL wins over all of this, which is how a\n * client reaches a second host (a signed upload endpoint, a CDN) without a\n * second client. Without `params` it is returned exactly as given: a signed\n * URL covers its query, and a round trip through `URL` re-encodes characters\n * outside the canonical form (`'` became `%27`, measured), which is a rewrite\n * of a string the server signed.\n *\n * @example\n * buildApiUrl(\"https://api.example.com\", \"/auth/login\", { prefix: \"/api\" });\n * // \"https://api.example.com/api/auth/login\"\n *\n * buildApiUrl(\"https://api.example.com/api\", \"auth/login\");\n * // \"https://api.example.com/api/auth/login\"\n *\n * @param baseURL - Absolute base URL, or a path relative to the current origin.\n * @param path - The request path, or an absolute URL to use as-is.\n * @param options - Optional prefix and query params.\n * @returns The absolute URL to fetch.\n * @throws When `baseURL` is relative and there is no `location` to resolve it.\n */\nexport function buildApiUrl(\n baseURL: string,\n path: string,\n options: BuildApiUrlOptions = {},\n): string {\n const { prefix, params } = options;\n if (ABSOLUTE_URL.test(path) && !Object.values(params ?? {}).some((value) => value != null)) {\n return path;\n }\n const url = ABSOLUTE_URL.test(path) ? new URL(path) : new URL(resolve(baseURL, path, prefix));\n\n if (params) {\n for (const [key, value] of Object.entries(params)) {\n if (value !== undefined && value !== null) {\n url.searchParams.set(key, String(value));\n }\n }\n }\n return url.toString();\n}\n\n/**\n * Build the absolute URL string for a path that is not already absolute.\n *\n * Kept separate from {@link buildApiUrl} so the query-param loop is not nested\n * inside the joining rules.\n *\n * @param baseURL - Absolute base URL, or a path relative to the current origin.\n * @param path - The request path.\n * @param prefix - Optional segment every request is nested under.\n * @returns The joined absolute URL.\n */\nfunction resolve(baseURL: string, path: string, prefix?: string): string {\n const base = ABSOLUTE_URL.test(baseURL)\n ? new URL(baseURL)\n : new URL(baseURL, currentOrigin(baseURL));\n\n const queryStart = path.indexOf(\"?\");\n const rawPath = queryStart === -1 ? path : path.slice(0, queryStart);\n const query = queryStart === -1 ? \"\" : path.slice(queryStart);\n\n const basePath = [...segments(base.pathname), ...segments(prefix ?? \"\")];\n const requested = segments(rawPath);\n const joined = startsWithSegments(requested, basePath)\n ? requested\n : [...basePath, ...requested];\n\n const trailing = rawPath.endsWith(\"/\") ? \"/\" : \"\";\n const pathname = joined.length > 0 ? `/${joined.join(\"/\")}${trailing}` : \"/\";\n return `${base.origin}${pathname}${query}`;\n}\n"],"mappings":"AAIA,IAAMA,EAAe,uBAGrB,SAASC,EAAS,EAAwB,CACtC,OAAOC,EAAK,MAAM,GAAG,CAAC,CAAC,OAAO,OAAO,CACzC,CAYA,SAASC,EAAmB,EAAgB,EAA2B,CAEnE,OADIC,EAAO,SAAW,GAAKA,EAAO,OAASF,EAAK,OAAe,GACxDE,EAAO,OAAO,EAAS,IAAUF,EAAKG,KAAWC,CAAO,CACnE,CAcA,SAASC,EAAc,EAAyB,CAC5C,IAAMC,EAAS,WAAW,UAAU,OACpC,GAAI,CAACA,EACD,MAAU,UACN,6BAA6BC,EAAQ,kIACzC,EAEJ,OAAOD,CACX,CAmDA,SAAgBE,EACZ,EACA,EACA,EAA8B,CAAC,EACzB,CACN,GAAM,CAAE,SAAQ,UAAWC,EAC3B,GAAIX,EAAa,KAAKE,CAAI,GAAK,CAAC,OAAO,OAAOU,GAAU,CAAC,CAAC,CAAC,CAAC,KAAM,GAAUC,GAAS,IAAI,EACrF,OAAOX,EAEX,IAAMY,EAAMd,EAAa,KAAKE,CAAI,EAAI,IAAI,IAAIA,CAAI,EAAI,IAAI,IAAIa,EAAQN,EAASP,EAAME,CAAM,CAAC,EAE5F,GAAIQ,EACK,IAAA,GAAM,CAACI,EAAKH,KAAU,OAAO,QAAQD,CAAM,EACxCC,GAAiC,MACjCC,EAAI,aAAa,IAAIE,EAAK,OAAOH,CAAK,CAAC,EAInD,OAAOC,EAAI,SAAS,CACxB,CAaA,SAASC,EAAQ,EAAiB,EAAc,EAAyB,CACrE,IAAME,EAAOjB,EAAa,KAAKS,CAAO,EAChC,IAAI,IAAIA,CAAO,EACf,IAAI,IAAIA,EAASF,EAAcE,CAAO,CAAC,EAEvCS,EAAahB,EAAK,QAAQ,GAAG,EAC7BiB,EAAUD,IAAe,GAAKhB,EAAOA,EAAK,MAAM,EAAGgB,CAAU,EAC7DE,EAAQF,IAAe,GAAK,GAAKhB,EAAK,MAAMgB,CAAU,EAEtDG,EAAW,CAAC,GAAGpB,EAASgB,EAAK,QAAQ,EAAG,GAAGhB,EAASG,GAAU,EAAE,CAAC,EACjEkB,EAAYrB,EAASkB,CAAO,EAC5BI,EAASpB,EAAmBmB,EAAWD,CAAQ,EAC/CC,EACA,CAAC,GAAGD,EAAU,GAAGC,CAAS,EAE1BE,EAAWL,EAAQ,SAAS,GAAG,EAAI,IAAM,GACzCM,EAAWF,EAAO,OAAS,EAAI,IAAIA,EAAO,KAAK,GAAG,IAAIC,IAAa,IACzE,MAAO,GAAGP,EAAK,SAASQ,IAAWL,GACvC,C"}
|
package/dist/http/build-url.js
CHANGED
|
@@ -12,7 +12,9 @@ function r(e) {
|
|
|
12
12
|
return t;
|
|
13
13
|
}
|
|
14
14
|
function i(t, n, r = {}) {
|
|
15
|
-
let { prefix: i, params: o } = r
|
|
15
|
+
let { prefix: i, params: o } = r;
|
|
16
|
+
if (e.test(n) && !Object.values(o ?? {}).some((e) => e != null)) return n;
|
|
17
|
+
let s = e.test(n) ? new URL(n) : new URL(a(t, n, i));
|
|
16
18
|
if (o) for (let [e, t] of Object.entries(o)) t != null && s.searchParams.set(e, String(t));
|
|
17
19
|
return s.toString();
|
|
18
20
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"build-url.js","names":["ABSOLUTE_URL","segments","path","startsWithSegments","prefix","index","segment","currentOrigin","origin","baseURL","buildApiUrl","options","
|
|
1
|
+
{"version":3,"file":"build-url.js","names":["ABSOLUTE_URL","segments","path","startsWithSegments","prefix","index","segment","currentOrigin","origin","baseURL","buildApiUrl","options","params","value","url","resolve","key","base","queryStart","rawPath","query","basePath","requested","joined","trailing","pathname"],"sources":["../../src/http/build-url.ts"],"sourcesContent":["/**\n * Matches a path that already carries its own scheme (`https:`, `blob:`), which\n * makes it an absolute URL rather than something to resolve against the base.\n */\nconst ABSOLUTE_URL = /^[a-z][a-z\\d+\\-.]*:/i;\n\n/** Split a path into its non-empty segments, dropping surrounding slashes. */\nfunction segments(path: string): string[] {\n return path.split(\"/\").filter(Boolean);\n}\n\n/**\n * Whether `path` already starts with every segment of `prefix`.\n *\n * Compared segment by segment, so a `/api` prefix is not considered present in\n * `/api-keys` — a plain `startsWith` would swallow the resource.\n *\n * @param path - Segments of the request path.\n * @param prefix - Segments of the base path.\n * @returns Whether the prefix is already applied.\n */\nfunction startsWithSegments(path: string[], prefix: string[]): boolean {\n if (prefix.length === 0 || prefix.length > path.length) return false;\n return prefix.every((segment, index) => path[index] === segment);\n}\n\n/**\n * Resolve the origin a relative `baseURL` hangs off.\n *\n * A base such as `\"/api\"` is the shape you get behind a dev-server proxy or a\n * reverse proxy that serves app and API from one host. It only means something\n * in a browsing context, so this throws with the config to fix rather than\n * letting `new URL` report `Invalid base URL` from three frames deeper.\n *\n * @param baseURL - The base as the caller wrote it.\n * @returns The origin to resolve against.\n * @throws When there is no `location` to borrow an origin from.\n */\nfunction currentOrigin(baseURL: string): string {\n const origin = globalThis.location?.origin;\n if (!origin) {\n throw new TypeError(\n `createApiClient: baseURL \"${baseURL}\" is relative and there is no location to resolve it against. Pass an absolute URL (https://api.example.com) outside the browser.`,\n );\n }\n return origin;\n}\n\n/** Options accepted by {@link buildApiUrl}. */\nexport interface BuildApiUrlOptions {\n /**\n * Path segment every request is nested under, such as `\"/api\"`. Joined\n * after the path the `baseURL` already carries.\n */\n prefix?: string;\n /** Query params to append. `undefined` and `null` values are skipped. */\n params?: Record<string, string | number | boolean | undefined | null>;\n}\n\n/**\n * Join a base URL, an optional prefix and a request path into an absolute URL.\n *\n * `new URL(path, base)` on its own is wrong for an API client. It follows the\n * URL spec, where a path starting with `/` is absolute against the *origin* and\n * therefore discards whatever path the base carried: a client on\n * `https://api.example.com/api` asked for `/auth/login` reaches\n * `https://api.example.com/auth/login`, and every request 404s with nothing in\n * the config that looks wrong. This function resolves the path against the base\n * *path* instead, so the leading slash is a matter of taste rather than a\n * silent 404, and `baseURL` + `prefix` are interchangeable ways to say the same\n * thing.\n *\n * The prefix is applied at most once: a path that already opens with it — say\n * `\"/api/auth/login\"` under a `\"/api\"` prefix — is left alone, so a codebase\n * migrating to `prefix` can move its call sites one at a time. The check is per\n * segment, so `/api-keys` is not mistaken for an already-prefixed path.\n *\n * A path that is itself an absolute URL wins over all of this, which is how a\n * client reaches a second host (a signed upload endpoint, a CDN) without a\n * second client. Without `params` it is returned exactly as given: a signed\n * URL covers its query, and a round trip through `URL` re-encodes characters\n * outside the canonical form (`'` became `%27`, measured), which is a rewrite\n * of a string the server signed.\n *\n * @example\n * buildApiUrl(\"https://api.example.com\", \"/auth/login\", { prefix: \"/api\" });\n * // \"https://api.example.com/api/auth/login\"\n *\n * buildApiUrl(\"https://api.example.com/api\", \"auth/login\");\n * // \"https://api.example.com/api/auth/login\"\n *\n * @param baseURL - Absolute base URL, or a path relative to the current origin.\n * @param path - The request path, or an absolute URL to use as-is.\n * @param options - Optional prefix and query params.\n * @returns The absolute URL to fetch.\n * @throws When `baseURL` is relative and there is no `location` to resolve it.\n */\nexport function buildApiUrl(\n baseURL: string,\n path: string,\n options: BuildApiUrlOptions = {},\n): string {\n const { prefix, params } = options;\n if (ABSOLUTE_URL.test(path) && !Object.values(params ?? {}).some((value) => value != null)) {\n return path;\n }\n const url = ABSOLUTE_URL.test(path) ? new URL(path) : new URL(resolve(baseURL, path, prefix));\n\n if (params) {\n for (const [key, value] of Object.entries(params)) {\n if (value !== undefined && value !== null) {\n url.searchParams.set(key, String(value));\n }\n }\n }\n return url.toString();\n}\n\n/**\n * Build the absolute URL string for a path that is not already absolute.\n *\n * Kept separate from {@link buildApiUrl} so the query-param loop is not nested\n * inside the joining rules.\n *\n * @param baseURL - Absolute base URL, or a path relative to the current origin.\n * @param path - The request path.\n * @param prefix - Optional segment every request is nested under.\n * @returns The joined absolute URL.\n */\nfunction resolve(baseURL: string, path: string, prefix?: string): string {\n const base = ABSOLUTE_URL.test(baseURL)\n ? new URL(baseURL)\n : new URL(baseURL, currentOrigin(baseURL));\n\n const queryStart = path.indexOf(\"?\");\n const rawPath = queryStart === -1 ? path : path.slice(0, queryStart);\n const query = queryStart === -1 ? \"\" : path.slice(queryStart);\n\n const basePath = [...segments(base.pathname), ...segments(prefix ?? \"\")];\n const requested = segments(rawPath);\n const joined = startsWithSegments(requested, basePath)\n ? requested\n : [...basePath, ...requested];\n\n const trailing = rawPath.endsWith(\"/\") ? \"/\" : \"\";\n const pathname = joined.length > 0 ? `/${joined.join(\"/\")}${trailing}` : \"/\";\n return `${base.origin}${pathname}${query}`;\n}\n"],"mappings":";AAIA,IAAMA,IAAe;AAGrB,SAASC,EAAS,GAAwB;CACtC,OAAOC,EAAK,MAAM,GAAG,CAAC,CAAC,OAAO,OAAO;AACzC;AAYA,SAASC,EAAmB,GAAgB,GAA2B;CAEnE,OADIC,EAAO,WAAW,KAAKA,EAAO,SAASF,EAAK,SAAe,KACxDE,EAAO,OAAO,GAAS,MAAUF,EAAKG,OAAWC,CAAO;AACnE;AAcA,SAASC,EAAc,GAAyB;CAC5C,IAAMC,IAAS,WAAW,UAAU;CACpC,IAAI,CAACA,GACD,MAAU,UACN,6BAA6BC,EAAQ,kIACzC;CAEJ,OAAOD;AACX;AAmDA,SAAgBE,EACZ,GACA,GACA,IAA8B,CAAC,GACzB;CACN,IAAM,EAAE,WAAQ,cAAWC;CAC3B,IAAIX,EAAa,KAAKE,CAAI,KAAK,CAAC,OAAO,OAAOU,KAAU,CAAC,CAAC,CAAC,CAAC,MAAM,MAAUC,KAAS,IAAI,GACrF,OAAOX;CAEX,IAAMY,IAAMd,EAAa,KAAKE,CAAI,IAAI,IAAI,IAAIA,CAAI,IAAI,IAAI,IAAIa,EAAQN,GAASP,GAAME,CAAM,CAAC;CAE5F,IAAIQ,GACK,KAAA,IAAM,CAACI,GAAKH,MAAU,OAAO,QAAQD,CAAM,GAC5C,AAAIC,KAAiC,QACjCC,EAAI,aAAa,IAAIE,GAAK,OAAOH,CAAK,CAAC;CAInD,OAAOC,EAAI,SAAS;AACxB;AAaA,SAASC,EAAQ,GAAiB,GAAc,GAAyB;CACrE,IAAME,IAAOjB,EAAa,KAAKS,CAAO,IAChC,IAAI,IAAIA,CAAO,IACf,IAAI,IAAIA,GAASF,EAAcE,CAAO,CAAC,GAEvCS,IAAahB,EAAK,QAAQ,GAAG,GAC7BiB,IAAUD,MAAe,KAAKhB,IAAOA,EAAK,MAAM,GAAGgB,CAAU,GAC7DE,IAAQF,MAAe,KAAK,KAAKhB,EAAK,MAAMgB,CAAU,GAEtDG,IAAW,CAAC,GAAGpB,EAASgB,EAAK,QAAQ,GAAG,GAAGhB,EAASG,KAAU,EAAE,CAAC,GACjEkB,IAAYrB,EAASkB,CAAO,GAC5BI,IAASpB,EAAmBmB,GAAWD,CAAQ,IAC/CC,IACA,CAAC,GAAGD,GAAU,GAAGC,CAAS,GAE1BE,IAAWL,EAAQ,SAAS,GAAG,IAAI,MAAM,IACzCM,IAAWF,EAAO,SAAS,IAAI,IAAIA,EAAO,KAAK,GAAG,IAAIC,MAAa;CACzE,OAAO,GAAGP,EAAK,SAASQ,IAAWL;AACvC"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
const e=require("./errors.cjs"),t=require("./upload-with-progress.cjs");var n={EntityTooLarge:{status:413,code:`FILE_TOO_LARGE`,detail:`Arquivo maior que o permitido.`},EntityTooSmall:{status:400,code:`FILE_TOO_SMALL`,detail:`Arquivo menor que o permitido.`},AccessDenied:{status:403,code:`SIGNED_URL_INVALID`,detail:`O link de envio não aceita este arquivo.`}},r=/policy expired/i;function i(e,t){return RegExp(`<${t}>([^<]*)</${t}>`).exec(e)?.[1]}function a(t){if(!(t instanceof e.TempestApiError)||typeof t.body!=`string`)return t;let a=i(t.body,`Code`);if(!a)return t;let o=i(t.body,`Message`)??``,s=a===`AccessDenied`&&r.test(o)?{status:403,code:`SIGNED_URL_EXPIRED`,detail:`O link de envio expirou.`}:n[a]??{status:t.status,code:a,detail:o};return new e.TempestApiError({status:s.status,code:s.code,detail:s.detail||t.detail,requestId:i(t.body,`RequestId`)??t.requestId,body:t.body})}async function o(n,r,i={}){let{onProgress:o,signal:s,fileName:c}=i;if(r.size>n.max_bytes)throw new e.TempestApiError({status:413,code:`FILE_TOO_LARGE`,detail:`Arquivo de ${r.size} bytes excede o limite de ${n.max_bytes} bytes.`});let l=new FormData;for(let[e,t]of Object.entries(n.fields))l.append(e,t);c===void 0?l.append(`file`,r):l.append(`file`,r,c);try{await t.uploadWithProgress({url:n.url,body:l,method:`POST`,requestId:()=>``,withCredentials:!1,onProgress:o,signal:s,parser:()=>void 0})}catch(e){throw a(e)}}exports.uploadPresignedPost=o;
|
|
2
|
+
//# sourceMappingURL=presigned-post.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"presigned-post.cjs","names":["TempestApiError","uploadWithProgress","require_errors","require_upload_with_progress","S3_ERRORS","EXPIRED_POLICY","xmlText","tag","xml","normalizeError","error","s3Code","message","mapped","uploadPresignedPost","options","file","post","form","name","value","fileName"],"sources":["../../src/http/presigned-post.ts"],"sourcesContent":["import { TempestApiError } from \"./errors\";\nimport { uploadWithProgress } from \"./upload-with-progress\";\nimport type { UploadProgressEvent } from \"./upload-with-progress\";\n\n/**\n * A pre-signed `POST` form, as `tempest-fastapi-sdk` >= 0.331.0 hands it out\n * (`presigned_post_policy` / `SubjectObjectStorage.presign_post`).\n *\n * Snake case on purpose: this is the wire shape, so it parses straight from the\n * response of the app's own route.\n */\nexport interface PresignedPost {\n /** Where the form is posted: the bucket on S3/MinIO, or the API itself on the local backend. */\n url: string;\n /** Form fields to send before the file, in this order — `key`, `policy`, signature and the rest. */\n fields: Record<string, string>;\n /** Largest file the policy accepts, in bytes. */\n max_bytes: number;\n}\n\nexport interface UploadPresignedPostOptions {\n /** Called on every `progress` event of the upload. */\n onProgress?: (event: UploadProgressEvent) => void;\n /** Abort the upload; rejects with an `AbortError` `DOMException`. */\n signal?: AbortSignal;\n /** File name sent with the `file` part. Defaults to `file.name` for a `File`. */\n fileName?: string;\n}\n\n/**\n * S3 error codes mapped to the codes the local storage backend of\n * `tempest-fastapi-sdk` answers with, so one `code` reaches the UI whichever\n * backend the service is configured with.\n *\n * Statuses follow the local backend too: S3 answers `EntityTooLarge` with `400`,\n * the local backend `FILE_TOO_LARGE` with `413`. Measured against MinIO\n * `RELEASE.2025-04-22T22-12-26Z` from Chrome, cross-origin: the XML error body is\n * readable (MinIO sends CORS headers on errors), an expired policy and a broken\n * condition are both `AccessDenied` and differ only in the message\n * (`Policy expired` / `Policy Condition failed`).\n */\nconst S3_ERRORS: Readonly<Record<string, { status: number; code: string; detail: string }>> = {\n EntityTooLarge: {\n status: 413,\n code: \"FILE_TOO_LARGE\",\n detail: \"Arquivo maior que o permitido.\",\n },\n EntityTooSmall: {\n status: 400,\n code: \"FILE_TOO_SMALL\",\n detail: \"Arquivo menor que o permitido.\",\n },\n AccessDenied: {\n status: 403,\n code: \"SIGNED_URL_INVALID\",\n detail: \"O link de envio não aceita este arquivo.\",\n },\n};\n\nconst EXPIRED_POLICY = /policy expired/i;\n\n/**\n * Text of the first `<tag>` element in an S3 XML error body.\n *\n * @param xml - The response body.\n * @param tag - Element name.\n * @returns The element text, or `undefined` when absent.\n */\nfunction xmlText(xml: string, tag: string): string | undefined {\n return new RegExp(`<${tag}>([^<]*)</${tag}>`).exec(xml)?.[1];\n}\n\n/**\n * Rewrite an S3 XML error into the local backend's code and status.\n *\n * A JSON envelope from the local backend already carries `code` and passes\n * through untouched, as does any failure without an XML body (network, abort).\n *\n * @param error - What the upload rejected with.\n * @returns The error to surface.\n */\nfunction normalizeError(error: unknown): unknown {\n if (!(error instanceof TempestApiError) || typeof error.body !== \"string\") return error;\n const s3Code = xmlText(error.body, \"Code\");\n if (!s3Code) return error;\n const message = xmlText(error.body, \"Message\") ?? \"\";\n const mapped =\n s3Code === \"AccessDenied\" && EXPIRED_POLICY.test(message)\n ? {\n status: 403,\n code: \"SIGNED_URL_EXPIRED\",\n detail: \"O link de envio expirou.\",\n }\n : (S3_ERRORS[s3Code] ?? { status: error.status, code: s3Code, detail: message });\n return new TempestApiError({\n status: mapped.status,\n code: mapped.code,\n detail: mapped.detail || error.detail,\n requestId: xmlText(error.body, \"RequestId\") ?? error.requestId,\n body: error.body,\n });\n}\n\n/**\n * Upload a file straight to storage through a pre-signed `POST` form, with the\n * size ceiling the policy enforces.\n *\n * The form is built the way S3 requires: every `post.fields` entry in order,\n * the file last in the `file` part — S3 ignores whatever follows the file.\n *\n * The request carries **no** credential and no custom header: no\n * `Authorization`, no CSRF token, no `X-Request-ID`, no cookies. `post.url` is\n * usually the storage host, outside the API's origin, and the signature in\n * `fields` is the whole authorization. Without custom headers the request is a\n * CORS \"simple request\", so it does not depend on the bucket's CORS rules\n * allowing a preflight.\n *\n * A file larger than `post.max_bytes` is refused before anything is sent, with\n * the same `413 FILE_TOO_LARGE` the server would answer. Server errors are\n * normalized into one `TempestApiError` whichever backend answered: the S3 XML\n * (`EntityTooLarge` → `FILE_TOO_LARGE`, `EntityTooSmall` → `FILE_TOO_SMALL`,\n * `AccessDenied` → `SIGNED_URL_INVALID`, or `SIGNED_URL_EXPIRED` when the policy\n * expired) or the local backend's JSON envelope, which already uses those codes.\n *\n * @example\n * const post = await api.post<PresignedPost>(\"/avatars/upload-form\");\n * await uploadPresignedPost(post, file, { onProgress: (p) => setProgress(p.fraction) });\n *\n * @param post - The form the API handed out.\n * @param file - The file or blob to upload.\n * @param options - Progress callback, abort signal and file name.\n * @returns Resolves when storage accepted the file (`204` on S3).\n * @throws TempestApiError On a refused upload, with the normalized `code`.\n */\nexport async function uploadPresignedPost(\n post: PresignedPost,\n file: Blob,\n options: UploadPresignedPostOptions = {},\n): Promise<void> {\n const { onProgress, signal, fileName } = options;\n if (file.size > post.max_bytes) {\n throw new TempestApiError({\n status: 413,\n code: \"FILE_TOO_LARGE\",\n detail: `Arquivo de ${file.size} bytes excede o limite de ${post.max_bytes} bytes.`,\n });\n }\n\n const form = new FormData();\n for (const [name, value] of Object.entries(post.fields)) form.append(name, value);\n if (fileName !== undefined) form.append(\"file\", file, fileName);\n else form.append(\"file\", file);\n\n try {\n await uploadWithProgress({\n url: post.url,\n body: form,\n method: \"POST\",\n requestId: () => \"\",\n withCredentials: false,\n onProgress,\n signal,\n parser: () => undefined,\n });\n } catch (error) {\n throw normalizeError(error);\n }\n}\n"],"mappings":"A,wEAyCA,IAAMI,EAAwF,CAC1F,eAAgB,CACZ,OAAQ,IACR,KAAM,iBACN,OAAQ,gCACZ,EACA,eAAgB,CACZ,OAAQ,IACR,KAAM,iBACN,OAAQ,gCACZ,EACA,aAAc,CACV,OAAQ,IACR,KAAM,qBACN,OAAQ,0CACZ,CACJ,EAEMC,EAAiB,kBASvB,SAASC,EAAQ,EAAa,EAAiC,CAC3D,OAAW,OAAO,IAAIC,EAAI,YAAYA,EAAI,EAAE,CAAC,CAAC,KAAKC,CAAG,CAAC,GAAG,EAC9D,CAWA,SAASC,EAAe,EAAyB,CAC7C,GAAI,EAAEC,aAAiBV,EAAAA,kBAAoB,OAAOU,EAAM,MAAS,SAAU,OAAOA,EAClF,IAAMC,EAASL,EAAQI,EAAM,KAAM,MAAM,EACzC,GAAI,CAACC,EAAQ,OAAOD,EACpB,IAAME,EAAUN,EAAQI,EAAM,KAAM,SAAS,GAAK,GAC5CG,EACFF,IAAW,gBAAkBN,EAAe,KAAKO,CAAO,EAClD,CACI,OAAQ,IACR,KAAM,qBACN,OAAQ,0BACZ,EACCR,EAAUO,IAAW,CAAE,OAAQD,EAAM,OAAQ,KAAMC,EAAQ,OAAQC,CAAQ,EACtF,OAAO,IAAIZ,EAAAA,gBAAgB,CACvB,OAAQa,EAAO,OACf,KAAMA,EAAO,KACb,OAAQA,EAAO,QAAUH,EAAM,OAC/B,UAAWJ,EAAQI,EAAM,KAAM,WAAW,GAAKA,EAAM,UACrD,KAAMA,EAAM,IAChB,CAAC,CACL,CAiCA,eAAsBI,EAClB,EACA,EACA,EAAsC,CAAC,EAC1B,CACb,GAAM,CAAE,aAAY,SAAQ,YAAaC,EACzC,GAAIC,EAAK,KAAOC,EAAK,UACjB,MAAM,IAAIjB,EAAAA,gBAAgB,CACtB,OAAQ,IACR,KAAM,iBACN,OAAQ,cAAcgB,EAAK,KAAK,4BAA4BC,EAAK,UAAU,QAC/E,CAAC,EAGL,IAAMC,EAAO,IAAI,SACjB,IAAK,GAAM,CAACC,EAAMC,KAAU,OAAO,QAAQH,EAAK,MAAM,EAAGC,EAAK,OAAOC,EAAMC,CAAK,EAC5EC,IAAa,IAAA,GACZH,EAAK,OAAO,OAAQF,CAAI,EADDE,EAAK,OAAO,OAAQF,EAAMK,CAAQ,EAG9D,GAAI,CACA,MAAMpB,EAAAA,mBAAmB,CACrB,IAAKgB,EAAK,IACV,KAAMC,EACN,OAAQ,OACR,cAAiB,GACjB,gBAAiB,GACjB,aACA,SACA,WAAc,IAAA,EAClB,CAAC,CACL,OAASR,EAAO,CACZ,MAAMD,EAAeC,CAAK,CAC9B,CACJ,C"}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { TempestApiError as e } from "./errors.js";
|
|
2
|
+
import { uploadWithProgress as t } from "./upload-with-progress.js";
|
|
3
|
+
//#region src/http/presigned-post.ts
|
|
4
|
+
var n = {
|
|
5
|
+
EntityTooLarge: {
|
|
6
|
+
status: 413,
|
|
7
|
+
code: "FILE_TOO_LARGE",
|
|
8
|
+
detail: "Arquivo maior que o permitido."
|
|
9
|
+
},
|
|
10
|
+
EntityTooSmall: {
|
|
11
|
+
status: 400,
|
|
12
|
+
code: "FILE_TOO_SMALL",
|
|
13
|
+
detail: "Arquivo menor que o permitido."
|
|
14
|
+
},
|
|
15
|
+
AccessDenied: {
|
|
16
|
+
status: 403,
|
|
17
|
+
code: "SIGNED_URL_INVALID",
|
|
18
|
+
detail: "O link de envio não aceita este arquivo."
|
|
19
|
+
}
|
|
20
|
+
}, r = /policy expired/i;
|
|
21
|
+
function i(e, t) {
|
|
22
|
+
return RegExp(`<${t}>([^<]*)</${t}>`).exec(e)?.[1];
|
|
23
|
+
}
|
|
24
|
+
function a(t) {
|
|
25
|
+
if (!(t instanceof e) || typeof t.body != "string") return t;
|
|
26
|
+
let a = i(t.body, "Code");
|
|
27
|
+
if (!a) return t;
|
|
28
|
+
let o = i(t.body, "Message") ?? "", s = a === "AccessDenied" && r.test(o) ? {
|
|
29
|
+
status: 403,
|
|
30
|
+
code: "SIGNED_URL_EXPIRED",
|
|
31
|
+
detail: "O link de envio expirou."
|
|
32
|
+
} : n[a] ?? {
|
|
33
|
+
status: t.status,
|
|
34
|
+
code: a,
|
|
35
|
+
detail: o
|
|
36
|
+
};
|
|
37
|
+
return new e({
|
|
38
|
+
status: s.status,
|
|
39
|
+
code: s.code,
|
|
40
|
+
detail: s.detail || t.detail,
|
|
41
|
+
requestId: i(t.body, "RequestId") ?? t.requestId,
|
|
42
|
+
body: t.body
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
async function o(n, r, i = {}) {
|
|
46
|
+
let { onProgress: o, signal: s, fileName: c } = i;
|
|
47
|
+
if (r.size > n.max_bytes) throw new e({
|
|
48
|
+
status: 413,
|
|
49
|
+
code: "FILE_TOO_LARGE",
|
|
50
|
+
detail: `Arquivo de ${r.size} bytes excede o limite de ${n.max_bytes} bytes.`
|
|
51
|
+
});
|
|
52
|
+
let l = new FormData();
|
|
53
|
+
for (let [e, t] of Object.entries(n.fields)) l.append(e, t);
|
|
54
|
+
c === void 0 ? l.append("file", r) : l.append("file", r, c);
|
|
55
|
+
try {
|
|
56
|
+
await t({
|
|
57
|
+
url: n.url,
|
|
58
|
+
body: l,
|
|
59
|
+
method: "POST",
|
|
60
|
+
requestId: () => "",
|
|
61
|
+
withCredentials: !1,
|
|
62
|
+
onProgress: o,
|
|
63
|
+
signal: s,
|
|
64
|
+
parser: () => void 0
|
|
65
|
+
});
|
|
66
|
+
} catch (e) {
|
|
67
|
+
throw a(e);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
//#endregion
|
|
71
|
+
export { o as uploadPresignedPost };
|
|
72
|
+
|
|
73
|
+
//# sourceMappingURL=presigned-post.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"presigned-post.js","names":["S3_ERRORS","EXPIRED_POLICY","xmlText","tag","xml","normalizeError","error","TempestApiError","s3Code","message","mapped","uploadPresignedPost","options","file","post","form","name","value","fileName","uploadWithProgress"],"sources":["../../src/http/presigned-post.ts"],"sourcesContent":["import { TempestApiError } from \"./errors\";\nimport { uploadWithProgress } from \"./upload-with-progress\";\nimport type { UploadProgressEvent } from \"./upload-with-progress\";\n\n/**\n * A pre-signed `POST` form, as `tempest-fastapi-sdk` >= 0.331.0 hands it out\n * (`presigned_post_policy` / `SubjectObjectStorage.presign_post`).\n *\n * Snake case on purpose: this is the wire shape, so it parses straight from the\n * response of the app's own route.\n */\nexport interface PresignedPost {\n /** Where the form is posted: the bucket on S3/MinIO, or the API itself on the local backend. */\n url: string;\n /** Form fields to send before the file, in this order — `key`, `policy`, signature and the rest. */\n fields: Record<string, string>;\n /** Largest file the policy accepts, in bytes. */\n max_bytes: number;\n}\n\nexport interface UploadPresignedPostOptions {\n /** Called on every `progress` event of the upload. */\n onProgress?: (event: UploadProgressEvent) => void;\n /** Abort the upload; rejects with an `AbortError` `DOMException`. */\n signal?: AbortSignal;\n /** File name sent with the `file` part. Defaults to `file.name` for a `File`. */\n fileName?: string;\n}\n\n/**\n * S3 error codes mapped to the codes the local storage backend of\n * `tempest-fastapi-sdk` answers with, so one `code` reaches the UI whichever\n * backend the service is configured with.\n *\n * Statuses follow the local backend too: S3 answers `EntityTooLarge` with `400`,\n * the local backend `FILE_TOO_LARGE` with `413`. Measured against MinIO\n * `RELEASE.2025-04-22T22-12-26Z` from Chrome, cross-origin: the XML error body is\n * readable (MinIO sends CORS headers on errors), an expired policy and a broken\n * condition are both `AccessDenied` and differ only in the message\n * (`Policy expired` / `Policy Condition failed`).\n */\nconst S3_ERRORS: Readonly<Record<string, { status: number; code: string; detail: string }>> = {\n EntityTooLarge: {\n status: 413,\n code: \"FILE_TOO_LARGE\",\n detail: \"Arquivo maior que o permitido.\",\n },\n EntityTooSmall: {\n status: 400,\n code: \"FILE_TOO_SMALL\",\n detail: \"Arquivo menor que o permitido.\",\n },\n AccessDenied: {\n status: 403,\n code: \"SIGNED_URL_INVALID\",\n detail: \"O link de envio não aceita este arquivo.\",\n },\n};\n\nconst EXPIRED_POLICY = /policy expired/i;\n\n/**\n * Text of the first `<tag>` element in an S3 XML error body.\n *\n * @param xml - The response body.\n * @param tag - Element name.\n * @returns The element text, or `undefined` when absent.\n */\nfunction xmlText(xml: string, tag: string): string | undefined {\n return new RegExp(`<${tag}>([^<]*)</${tag}>`).exec(xml)?.[1];\n}\n\n/**\n * Rewrite an S3 XML error into the local backend's code and status.\n *\n * A JSON envelope from the local backend already carries `code` and passes\n * through untouched, as does any failure without an XML body (network, abort).\n *\n * @param error - What the upload rejected with.\n * @returns The error to surface.\n */\nfunction normalizeError(error: unknown): unknown {\n if (!(error instanceof TempestApiError) || typeof error.body !== \"string\") return error;\n const s3Code = xmlText(error.body, \"Code\");\n if (!s3Code) return error;\n const message = xmlText(error.body, \"Message\") ?? \"\";\n const mapped =\n s3Code === \"AccessDenied\" && EXPIRED_POLICY.test(message)\n ? {\n status: 403,\n code: \"SIGNED_URL_EXPIRED\",\n detail: \"O link de envio expirou.\",\n }\n : (S3_ERRORS[s3Code] ?? { status: error.status, code: s3Code, detail: message });\n return new TempestApiError({\n status: mapped.status,\n code: mapped.code,\n detail: mapped.detail || error.detail,\n requestId: xmlText(error.body, \"RequestId\") ?? error.requestId,\n body: error.body,\n });\n}\n\n/**\n * Upload a file straight to storage through a pre-signed `POST` form, with the\n * size ceiling the policy enforces.\n *\n * The form is built the way S3 requires: every `post.fields` entry in order,\n * the file last in the `file` part — S3 ignores whatever follows the file.\n *\n * The request carries **no** credential and no custom header: no\n * `Authorization`, no CSRF token, no `X-Request-ID`, no cookies. `post.url` is\n * usually the storage host, outside the API's origin, and the signature in\n * `fields` is the whole authorization. Without custom headers the request is a\n * CORS \"simple request\", so it does not depend on the bucket's CORS rules\n * allowing a preflight.\n *\n * A file larger than `post.max_bytes` is refused before anything is sent, with\n * the same `413 FILE_TOO_LARGE` the server would answer. Server errors are\n * normalized into one `TempestApiError` whichever backend answered: the S3 XML\n * (`EntityTooLarge` → `FILE_TOO_LARGE`, `EntityTooSmall` → `FILE_TOO_SMALL`,\n * `AccessDenied` → `SIGNED_URL_INVALID`, or `SIGNED_URL_EXPIRED` when the policy\n * expired) or the local backend's JSON envelope, which already uses those codes.\n *\n * @example\n * const post = await api.post<PresignedPost>(\"/avatars/upload-form\");\n * await uploadPresignedPost(post, file, { onProgress: (p) => setProgress(p.fraction) });\n *\n * @param post - The form the API handed out.\n * @param file - The file or blob to upload.\n * @param options - Progress callback, abort signal and file name.\n * @returns Resolves when storage accepted the file (`204` on S3).\n * @throws TempestApiError On a refused upload, with the normalized `code`.\n */\nexport async function uploadPresignedPost(\n post: PresignedPost,\n file: Blob,\n options: UploadPresignedPostOptions = {},\n): Promise<void> {\n const { onProgress, signal, fileName } = options;\n if (file.size > post.max_bytes) {\n throw new TempestApiError({\n status: 413,\n code: \"FILE_TOO_LARGE\",\n detail: `Arquivo de ${file.size} bytes excede o limite de ${post.max_bytes} bytes.`,\n });\n }\n\n const form = new FormData();\n for (const [name, value] of Object.entries(post.fields)) form.append(name, value);\n if (fileName !== undefined) form.append(\"file\", file, fileName);\n else form.append(\"file\", file);\n\n try {\n await uploadWithProgress({\n url: post.url,\n body: form,\n method: \"POST\",\n requestId: () => \"\",\n withCredentials: false,\n onProgress,\n signal,\n parser: () => undefined,\n });\n } catch (error) {\n throw normalizeError(error);\n }\n}\n"],"mappings":";;;AAyCA,IAAMA,IAAwF;CAC1F,gBAAgB;EACZ,QAAQ;EACR,MAAM;EACN,QAAQ;CACZ;CACA,gBAAgB;EACZ,QAAQ;EACR,MAAM;EACN,QAAQ;CACZ;CACA,cAAc;EACV,QAAQ;EACR,MAAM;EACN,QAAQ;CACZ;AACJ,GAEMC,IAAiB;AASvB,SAASC,EAAQ,GAAa,GAAiC;CAC3D,OAAW,OAAO,IAAIC,EAAI,YAAYA,EAAI,EAAE,CAAC,CAAC,KAAKC,CAAG,CAAC,GAAG;AAC9D;AAWA,SAASC,EAAe,GAAyB;CAC7C,IAAI,EAAEC,aAAiBC,MAAoB,OAAOD,EAAM,QAAS,UAAU,OAAOA;CAClF,IAAME,IAASN,EAAQI,EAAM,MAAM,MAAM;CACzC,IAAI,CAACE,GAAQ,OAAOF;CACpB,IAAMG,IAAUP,EAAQI,EAAM,MAAM,SAAS,KAAK,IAC5CI,IACFF,MAAW,kBAAkBP,EAAe,KAAKQ,CAAO,IAClD;EACI,QAAQ;EACR,MAAM;EACN,QAAQ;CACZ,IACCT,EAAUQ,MAAW;EAAE,QAAQF,EAAM;EAAQ,MAAME;EAAQ,QAAQC;CAAQ;CACtF,OAAO,IAAIF,EAAgB;EACvB,QAAQG,EAAO;EACf,MAAMA,EAAO;EACb,QAAQA,EAAO,UAAUJ,EAAM;EAC/B,WAAWJ,EAAQI,EAAM,MAAM,WAAW,KAAKA,EAAM;EACrD,MAAMA,EAAM;CAChB,CAAC;AACL;AAiCA,eAAsBK,EAClB,GACA,GACA,IAAsC,CAAC,GAC1B;CACb,IAAM,EAAE,eAAY,WAAQ,gBAAaC;CACzC,IAAIC,EAAK,OAAOC,EAAK,WACjB,MAAM,IAAIP,EAAgB;EACtB,QAAQ;EACR,MAAM;EACN,QAAQ,cAAcM,EAAK,KAAK,4BAA4BC,EAAK,UAAU;CAC/E,CAAC;CAGL,IAAMC,IAAO,IAAI,SAAS;CAC1B,KAAK,IAAM,CAACC,GAAMC,MAAU,OAAO,QAAQH,EAAK,MAAM,GAAG,EAAK,OAAOE,GAAMC,CAAK;CAChF,AAAIC,MAAa,KAAA,IACZH,EAAK,OAAO,QAAQF,CAAI,IADDE,EAAK,OAAO,QAAQF,GAAMK,CAAQ;CAG9D,IAAI;EACA,MAAMC,EAAmB;GACrB,KAAKL,EAAK;GACV,MAAMC;GACN,QAAQ;GACR,iBAAiB;GACjB,iBAAiB;GACjB;GACA;GACA,cAAc,KAAA;EAClB,CAAC;CACL,SAAST,GAAO;EACZ,MAAMD,EAAeC,CAAK;CAC9B;AACJ"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
const e=require("../utils/json-frame.cjs");function t(t,n={}){let{withCredentials:r=!1,namedEvents:i=[],heartbeatEvents:a=[`ping`],maxRetries:o=10,initialBackoff:s=1e3,maxBackoff:c=3e4,parser:l,onOpen:u,onMessage:d,onError:f,onParseError:p,schema:m,onValidationError:h,onStatusChange:
|
|
1
|
+
const e=require("../utils/json-frame.cjs");function t(t,n={}){let{withCredentials:r=!1,namedEvents:i=[],heartbeatEvents:a=[`ping`],maxRetries:o=10,initialBackoff:s=1e3,maxBackoff:c=3e4,parser:l,onOpen:u,onMessage:d,onError:f,onParseError:p,schema:m,onValidationError:h,onUrlError:g,shouldRetryUrl:_=()=>!0,onStatusChange:v}=n,y=null,b=null,x=0,S=`idle`,C=!1,w=0;function T(e){S!==e&&(S=e,v?.(e))}function E(t,n){if(a.includes(t))return;let r=e.decodeFrame(typeof n.data==`string`?n.data:``,`createEventStream`,{parser:l,onParseError:p,schema:m,onValidationError:h});r.delivered&&d?.({event:t,data:r.data,id:n.lastEventId||void 0,raw:n})}function D(){if(C)return;if(x>=o){T(`error`);return}let e=Math.min(s*2**x,c);x+=1,b=setTimeout(k,e)}function O(e){g?.(e),_(e)?(T(`closed`),D()):T(`error`)}function k(){if(C)return;if(y&&=(y.close(),null),T(`connecting`),w+=1,typeof t==`string`){A(t);return}let e=w;Promise.resolve().then(t).then(t=>{!C&&e===w&&A(t)},t=>{!C&&e===w&&O(t)})}function A(e){let t=new EventSource(e,{withCredentials:r});y=t,t.onopen=()=>{x=0,T(`open`),u?.()},t.onmessage=e=>E(`message`,e);for(let e of i)t.addEventListener(e,t=>E(e,t));for(let e of a)t.addEventListener(e,()=>{});t.onerror=e=>{f?.(e),t.close(),y=null,T(`closed`),D()}}function j(){C=!0,b&&=(clearTimeout(b),null),x=0,y&&=(y.close(),null),T(`closed`)}function M(){b&&=(clearTimeout(b),null),x=0,C=!1,k()}return k(),{close:j,reconnect:M,get status(){return S}}}exports.createEventStream=t;
|
|
2
2
|
//# sourceMappingURL=create-event-stream.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"create-event-stream.cjs","names":["decodeFrame","require_json_frame","createEventStream","options","source","retryTimer","retries","status","closed","setStatus","next","onStatusChange","emit","heartbeatEvents","eventName","decoded","event","onMessage","scheduleReconnect","maxRetries","delay","initialBackoff","maxBackoff","connect","es","url","onOpen","name","namedEvents","onError","close","reconnect"],"sources":["../../src/sse/create-event-stream.ts"],"sourcesContent":["/**\n * @tempest-limits function-lines — the body is the SSE line protocol — accumulate\n * `data:` lines until a blank line, track `id:` for Last-Event-ID, honour `retry:` —\n * plus the reconnect that uses the id it just tracked.\n */\nimport { decodeFrame } from \"../utils/json-frame\";\nimport type { SchemaIssue, SchemaLike } from \"../utils/schema-like\";\nexport type EventStreamStatus = \"idle\" | \"connecting\" | \"open\" | \"closed\" | \"error\";\n\nexport interface EventStreamMessage<T> {\n /** Server-named event (default `\"message\"`). */\n event: string;\n /** Parsed payload — validated when `schema` is set, JSON-decoded when possible, raw string otherwise. */\n data: T;\n /** Server-supplied id, if any. */\n id?: string;\n /** Raw `MessageEvent` for advanced cases. */\n raw: MessageEvent;\n}\n\nexport interface CreateEventStreamOptions<T> {\n /** Send cookies with the EventSource handshake. Default: false. */\n withCredentials?: boolean;\n /** Subscribe to named events in addition to `message`. */\n namedEvents?: readonly string[];\n /** Treat these named events as heartbeat-only (no callback). Default: `[\"ping\"]`. */\n heartbeatEvents?: readonly string[];\n /** Max reconnect attempts. Default: 10. Pass 0 to disable reconnect. */\n maxRetries?: number;\n /** Initial backoff in ms; doubles per attempt, capped at `maxBackoff`. Default: 1000. */\n initialBackoff?: number;\n /** Maximum backoff in ms. Default: 30000. */\n maxBackoff?: number;\n /** Parse `event.data`. Defaults to JSON with raw-string fallback. */\n parser?: (raw: string) => T;\n /**\n * A frame arrived that is not valid JSON, and no `parser` was supplied.\n *\n * Registering this drops the frame instead of delivering it: the previous\n * behaviour handed `onMessage` the raw `string` announced as your message\n * type, so the failure surfaced later, at the first property access, with\n * nothing left pointing at the parse. Leave it out and that behaviour is\n * kept, with a one-time warning in development builds.\n */\n onParseError?: (error: unknown, raw: string) => void;\n /**\n * Schema every decoded frame must satisfy, from zod, valibot, arktype or\n * anything else exposing `~standard` or `.safeParse`.\n *\n * Without it nothing changes: the payload reaches `onMessage` announced as\n * `T` on the strength of the type argument alone, which is a promise about\n * the server that TypeScript cannot keep. With it, a frame that does not\n * match is **not** delivered — the same rule `onParseError` already follows —\n * and `onValidationError` hears why. The value delivered is the schema's\n * output, so coercions and defaults are honoured.\n *\n * When `parser` is also supplied, it decodes first and the schema validates\n * what it returned.\n *\n * The validation must be synchronous. A frame is decoded inside the\n * `message` handler and delivered from it, so an async schema would deliver\n * frames in whatever order their validations settled; that case is reported\n * through `onValidationError` instead of awaited.\n */\n schema?: SchemaLike<T>;\n /**\n * A frame was decoded but the `schema` refused it, so it was dropped.\n *\n * The one signal that does not depend on how the app's bundler resolves\n * `process`: the one-time development warning behind `onParseError` needs\n * `isDevBuild()` to be able to answer, and this callback is the app's own.\n */\n onValidationError?: (issues: SchemaIssue[], raw: string) => void;\n onOpen?: () => void;\n onMessage?: (message: EventStreamMessage<T>) => void;\n onError?: (error: Event) => void;\n onStatusChange?: (status: EventStreamStatus) => void;\n}\n\nexport interface EventStreamController {\n close: () => void;\n /** Force an immediate reconnect, resetting the retry counter. */\n reconnect: () => void;\n /** Current connection status. */\n readonly status: EventStreamStatus;\n}\n\n/**\n * Open a Server-Sent Events stream with automatic exponential-backoff reconnect.\n *\n * Heartbeat events (default `\"ping\"`) keep the socket alive without firing\n * `onMessage`. Pass `withCredentials: true` when the backend authenticates via\n * cookies. Call `close()` from the returned controller to tear down.\n *\n * @param url - Full SSE endpoint URL.\n * @param options - Stream configuration and callbacks.\n * @returns A controller exposing `close`, `reconnect` and the current `status`.\n */\nexport function createEventStream<T = unknown>(\n url: string,\n options: CreateEventStreamOptions<T> = {},\n): EventStreamController {\n const {\n withCredentials = false,\n namedEvents = [],\n heartbeatEvents = [\"ping\"],\n maxRetries = 10,\n initialBackoff = 1000,\n maxBackoff = 30000,\n parser,\n onOpen,\n onMessage,\n onError,\n onParseError,\n schema,\n onValidationError,\n onStatusChange,\n } = options;\n\n let source: EventSource | null = null;\n let retryTimer: ReturnType<typeof setTimeout> | null = null;\n let retries = 0;\n let status: EventStreamStatus = \"idle\";\n let closed = false;\n\n function setStatus(next: EventStreamStatus): void {\n if (status === next) return;\n status = next;\n onStatusChange?.(next);\n }\n\n function emit(eventName: string, event: MessageEvent): void {\n if (heartbeatEvents.includes(eventName)) return;\n const decoded = decodeFrame<T>(\n typeof event.data === \"string\" ? event.data : \"\",\n \"createEventStream\",\n { parser, onParseError, schema, onValidationError },\n );\n if (!decoded.delivered) return;\n onMessage?.({\n event: eventName,\n data: decoded.data,\n id: event.lastEventId || undefined,\n raw: event,\n });\n }\n\n function scheduleReconnect(): void {\n if (closed) return;\n if (retries >= maxRetries) {\n setStatus(\"error\");\n return;\n }\n const delay = Math.min(initialBackoff * 2 ** retries, maxBackoff);\n retries += 1;\n retryTimer = setTimeout(connect, delay);\n }\n\n function connect(): void {\n if (closed) return;\n if (source) source.close();\n setStatus(\"connecting\");\n\n const es = new EventSource(url, { withCredentials });\n source = es;\n\n es.onopen = () => {\n retries = 0;\n setStatus(\"open\");\n onOpen?.();\n };\n\n es.onmessage = (event) => emit(\"message\", event);\n for (const name of namedEvents) {\n es.addEventListener(name, (event) => emit(name, event as MessageEvent));\n }\n for (const name of heartbeatEvents) {\n es.addEventListener(name, () => {\n /* heartbeat — keep socket alive */\n });\n }\n\n es.onerror = (event) => {\n onError?.(event);\n es.close();\n source = null;\n setStatus(\"closed\");\n scheduleReconnect();\n };\n }\n\n function close(): void {\n closed = true;\n if (retryTimer) {\n clearTimeout(retryTimer);\n retryTimer = null;\n }\n retries = 0;\n if (source) {\n source.close();\n source = null;\n }\n setStatus(\"closed\");\n }\n\n function reconnect(): void {\n if (retryTimer) {\n clearTimeout(retryTimer);\n retryTimer = null;\n }\n retries = 0;\n closed = false;\n connect();\n }\n\n connect();\n\n return {\n close,\n reconnect,\n get status() {\n return status;\n },\n };\n}\n"],"mappings":"A,2CAkGA,SAAgBE,EACZ,EACA,EAAuC,CAAC,EACnB,CACrB,GAAM,CACF,kBAAkB,GAClB,cAAc,CAAC,EACf,kBAAkB,CAAC,MAAM,EACzB,aAAa,GACb,iBAAiB,IACjB,aAAa,IACb,SACA,SACA,YACA,UACA,eACA,SACA,oBACA,kBACAC,EAEAC,EAA6B,KAC7BC,EAAmD,KACnDC,EAAU,EACVC,EAA4B,OAC5BC,EAAS,GAEb,SAASC,EAAU,EAA+B,CAC1CF,IAAWG,IACf,EAASA,EACTC,IAAiBD,CAAI,EACzB,CAEA,SAASE,EAAK,EAAmB,EAA2B,CACxD,GAAIC,EAAgB,SAASC,CAAS,EAAG,OACzC,IAAMC,EAAUf,EAAAA,YACZ,OAAOgB,EAAM,MAAS,SAAWA,EAAM,KAAO,GAC9C,oBACA,CAAE,SAAQ,eAAc,SAAQ,mBAAkB,CACtD,EACKD,EAAQ,WACbE,IAAY,CACR,MAAOH,EACP,KAAMC,EAAQ,KACd,GAAIC,EAAM,aAAe,IAAA,GACzB,IAAKA,CACT,CAAC,CACL,CAEA,SAASE,GAA0B,CAC/B,GAAIV,EAAQ,OACZ,GAAIF,GAAWa,EAAY,CACvBV,EAAU,OAAO,EACjB,MACJ,CACA,IAAMW,EAAQ,KAAK,IAAIC,EAAiB,GAAKf,EAASgB,CAAU,EAChE,GAAW,EACX,EAAa,WAAWC,EAASH,CAAK,CAC1C,CAEA,SAASG,GAAgB,CACrB,GAAIf,EAAQ,OACRJ,GAAQA,EAAO,MAAM,EACzBK,EAAU,YAAY,EAEtB,IAAMe,EAAK,IAAI,YAAYC,EAAK,CAAE,iBAAgB,CAAC,EACnD,EAASD,EAET,EAAG,WAAe,CACd,EAAU,EACVf,EAAU,MAAM,EAChBiB,IAAS,CACb,EAEA,EAAG,UAAa,GAAUd,EAAK,UAAWI,CAAK,EAC/C,IAAK,IAAMW,KAAQC,EACfJ,EAAG,iBAAiBG,EAAO,GAAUf,EAAKe,EAAMX,CAAqB,CAAC,EAE1E,IAAK,IAAMW,KAAQd,EACfW,EAAG,iBAAiBG,MAAY,CAEhC,CAAC,EAGL,EAAG,QAAW,GAAU,CACpBE,IAAUb,CAAK,EACfQ,EAAG,MAAM,EACT,EAAS,KACTf,EAAU,QAAQ,EAClBS,EAAkB,CACtB,CACJ,CAEA,SAASY,GAAc,CACnB,EAAS,GACT,AAEIzB,KADA,aAAaA,CAAU,EACV,MAEjB,EAAU,EACV,AAEID,KADAA,EAAO,MAAM,EACJ,MAEbK,EAAU,QAAQ,CACtB,CAEA,SAASsB,GAAkB,CACvB,AAEI1B,KADA,aAAaA,CAAU,EACV,MAEjB,EAAU,EACV,EAAS,GACTkB,EAAQ,CACZ,CAIA,OAFAA,EAAQ,EAED,CACH,QACA,YACA,IAAI,QAAS,CACT,OAAOhB,CACX,CACJ,CACJ,C"}
|
|
1
|
+
{"version":3,"file":"create-event-stream.cjs","names":["decodeFrame","require_json_frame","createEventStream","options","source","retryTimer","retries","status","closed","attempt","setStatus","next","onStatusChange","emit","heartbeatEvents","eventName","decoded","event","onMessage","scheduleReconnect","maxRetries","delay","initialBackoff","maxBackoff","connect","failUrl","onUrlError","error","shouldRetryUrl","url","open","current","resolved","es","target","onOpen","name","namedEvents","onError","close","reconnect"],"sources":["../../src/sse/create-event-stream.ts"],"sourcesContent":["/**\n * @tempest-limits function-lines — the body is the SSE line protocol — accumulate\n * `data:` lines until a blank line, track `id:` for Last-Event-ID, honour `retry:` —\n * plus the reconnect that uses the id it just tracked.\n */\nimport { decodeFrame } from \"../utils/json-frame\";\nimport type { SchemaIssue, SchemaLike } from \"../utils/schema-like\";\nexport type EventStreamStatus = \"idle\" | \"connecting\" | \"open\" | \"closed\" | \"error\";\n\n/**\n * Where the stream connects: a fixed URL, or a function resolved again before\n * every connection, the first one and each reconnect.\n *\n * The function form exists for short-lived credentials carried in the URL,\n * such as the `?ticket=` of the `tempest-fastapi-sdk` SSE router. `EventSource`\n * cannot send headers, so the credential has to ride in the query, and one that\n * expires makes a fixed URL a trap: the reconnect after the expiry presents the\n * same dead credential, gets a `401`, and burns every retry on it while the user\n * is still signed in.\n */\nexport type EventStreamUrl = string | (() => string | Promise<string>);\n\nexport interface EventStreamMessage<T> {\n /** Server-named event (default `\"message\"`). */\n event: string;\n /** Parsed payload — validated when `schema` is set, JSON-decoded when possible, raw string otherwise. */\n data: T;\n /** Server-supplied id, if any. */\n id?: string;\n /** Raw `MessageEvent` for advanced cases. */\n raw: MessageEvent;\n}\n\nexport interface CreateEventStreamOptions<T> {\n /** Send cookies with the EventSource handshake. Default: false. */\n withCredentials?: boolean;\n /** Subscribe to named events in addition to `message`. */\n namedEvents?: readonly string[];\n /** Treat these named events as heartbeat-only (no callback). Default: `[\"ping\"]`. */\n heartbeatEvents?: readonly string[];\n /** Max reconnect attempts. Default: 10. Pass 0 to disable reconnect. */\n maxRetries?: number;\n /** Initial backoff in ms; doubles per attempt, capped at `maxBackoff`. Default: 1000. */\n initialBackoff?: number;\n /** Maximum backoff in ms. Default: 30000. */\n maxBackoff?: number;\n /** Parse `event.data`. Defaults to JSON with raw-string fallback. */\n parser?: (raw: string) => T;\n /**\n * A frame arrived that is not valid JSON, and no `parser` was supplied.\n *\n * Registering this drops the frame instead of delivering it: the previous\n * behaviour handed `onMessage` the raw `string` announced as your message\n * type, so the failure surfaced later, at the first property access, with\n * nothing left pointing at the parse. Leave it out and that behaviour is\n * kept, with a one-time warning in development builds.\n */\n onParseError?: (error: unknown, raw: string) => void;\n /**\n * Schema every decoded frame must satisfy, from zod, valibot, arktype or\n * anything else exposing `~standard` or `.safeParse`.\n *\n * Without it nothing changes: the payload reaches `onMessage` announced as\n * `T` on the strength of the type argument alone, which is a promise about\n * the server that TypeScript cannot keep. With it, a frame that does not\n * match is **not** delivered — the same rule `onParseError` already follows —\n * and `onValidationError` hears why. The value delivered is the schema's\n * output, so coercions and defaults are honoured.\n *\n * When `parser` is also supplied, it decodes first and the schema validates\n * what it returned.\n *\n * The validation must be synchronous. A frame is decoded inside the\n * `message` handler and delivered from it, so an async schema would deliver\n * frames in whatever order their validations settled; that case is reported\n * through `onValidationError` instead of awaited.\n */\n schema?: SchemaLike<T>;\n /**\n * A frame was decoded but the `schema` refused it, so it was dropped.\n *\n * The one signal that does not depend on how the app's bundler resolves\n * `process`: the one-time development warning behind `onParseError` needs\n * `isDevBuild()` to be able to answer, and this callback is the app's own.\n */\n onValidationError?: (issues: SchemaIssue[], raw: string) => void;\n /**\n * The URL function threw or rejected, so no connection was attempted.\n *\n * `onError` cannot carry this: it receives the `EventSource` `Event`, and a\n * URL that never resolved never had an `EventSource`.\n */\n onUrlError?: (error: unknown) => void;\n /**\n * Whether a URL-resolution failure is worth another attempt. Default: always.\n *\n * Return `false` for failures no backoff can fix — a `401` or a `403` when\n * fetching a ticket is a session decision, not a network blip — and the stream\n * stops at `status: \"error\"` instead of spending `maxRetries` on it.\n */\n shouldRetryUrl?: (error: unknown) => boolean;\n onOpen?: () => void;\n onMessage?: (message: EventStreamMessage<T>) => void;\n onError?: (error: Event) => void;\n onStatusChange?: (status: EventStreamStatus) => void;\n}\n\nexport interface EventStreamController {\n close: () => void;\n /** Force an immediate reconnect, resetting the retry counter. */\n reconnect: () => void;\n /** Current connection status. */\n readonly status: EventStreamStatus;\n}\n\n/**\n * Open a Server-Sent Events stream with automatic exponential-backoff reconnect.\n *\n * Heartbeat events (default `\"ping\"`) keep the socket alive without firing\n * `onMessage`. Pass `withCredentials: true` when the backend authenticates via\n * cookies. Call `close()` from the returned controller to tear down.\n *\n * When `url` is a function it is called before every connection, so a\n * reconnect presents a fresh credential instead of the one that expired. A\n * resolution still pending when `close()` or `reconnect()` runs is discarded.\n *\n * @param url - Full SSE endpoint URL, or a function returning it per connection.\n * @param options - Stream configuration and callbacks.\n * @returns A controller exposing `close`, `reconnect` and the current `status`.\n */\nexport function createEventStream<T = unknown>(\n url: EventStreamUrl,\n options: CreateEventStreamOptions<T> = {},\n): EventStreamController {\n const {\n withCredentials = false,\n namedEvents = [],\n heartbeatEvents = [\"ping\"],\n maxRetries = 10,\n initialBackoff = 1000,\n maxBackoff = 30000,\n parser,\n onOpen,\n onMessage,\n onError,\n onParseError,\n schema,\n onValidationError,\n onUrlError,\n shouldRetryUrl = () => true,\n onStatusChange,\n } = options;\n\n let source: EventSource | null = null;\n let retryTimer: ReturnType<typeof setTimeout> | null = null;\n let retries = 0;\n let status: EventStreamStatus = \"idle\";\n let closed = false;\n let attempt = 0;\n\n function setStatus(next: EventStreamStatus): void {\n if (status === next) return;\n status = next;\n onStatusChange?.(next);\n }\n\n function emit(eventName: string, event: MessageEvent): void {\n if (heartbeatEvents.includes(eventName)) return;\n const decoded = decodeFrame<T>(\n typeof event.data === \"string\" ? event.data : \"\",\n \"createEventStream\",\n { parser, onParseError, schema, onValidationError },\n );\n if (!decoded.delivered) return;\n onMessage?.({\n event: eventName,\n data: decoded.data,\n id: event.lastEventId || undefined,\n raw: event,\n });\n }\n\n function scheduleReconnect(): void {\n if (closed) return;\n if (retries >= maxRetries) {\n setStatus(\"error\");\n return;\n }\n const delay = Math.min(initialBackoff * 2 ** retries, maxBackoff);\n retries += 1;\n retryTimer = setTimeout(connect, delay);\n }\n\n function failUrl(error: unknown): void {\n onUrlError?.(error);\n if (!shouldRetryUrl(error)) {\n setStatus(\"error\");\n return;\n }\n setStatus(\"closed\");\n scheduleReconnect();\n }\n\n function connect(): void {\n if (closed) return;\n if (source) {\n source.close();\n source = null;\n }\n setStatus(\"connecting\");\n attempt += 1;\n if (typeof url === \"string\") {\n open(url);\n return;\n }\n const current = attempt;\n Promise.resolve()\n .then(url)\n .then(\n (resolved) => {\n if (!closed && current === attempt) open(resolved);\n },\n (error: unknown) => {\n if (!closed && current === attempt) failUrl(error);\n },\n );\n }\n\n function open(target: string): void {\n const es = new EventSource(target, { withCredentials });\n source = es;\n\n es.onopen = () => {\n retries = 0;\n setStatus(\"open\");\n onOpen?.();\n };\n\n es.onmessage = (event) => emit(\"message\", event);\n for (const name of namedEvents) {\n es.addEventListener(name, (event) => emit(name, event as MessageEvent));\n }\n for (const name of heartbeatEvents) {\n es.addEventListener(name, () => {\n /* heartbeat — keep socket alive */\n });\n }\n\n es.onerror = (event) => {\n onError?.(event);\n es.close();\n source = null;\n setStatus(\"closed\");\n scheduleReconnect();\n };\n }\n\n function close(): void {\n closed = true;\n if (retryTimer) {\n clearTimeout(retryTimer);\n retryTimer = null;\n }\n retries = 0;\n if (source) {\n source.close();\n source = null;\n }\n setStatus(\"closed\");\n }\n\n function reconnect(): void {\n if (retryTimer) {\n clearTimeout(retryTimer);\n retryTimer = null;\n }\n retries = 0;\n closed = false;\n connect();\n }\n\n connect();\n\n return {\n close,\n reconnect,\n get status() {\n return status;\n },\n };\n}\n"],"mappings":"A,2CAkIA,SAAgBE,EACZ,EACA,EAAuC,CAAC,EACnB,CACrB,GAAM,CACF,kBAAkB,GAClB,cAAc,CAAC,EACf,kBAAkB,CAAC,MAAM,EACzB,aAAa,GACb,iBAAiB,IACjB,aAAa,IACb,SACA,SACA,YACA,UACA,eACA,SACA,oBACA,aACA,qBAAuB,GACvB,kBACAC,EAEAC,EAA6B,KAC7BC,EAAmD,KACnDC,EAAU,EACVC,EAA4B,OAC5BC,EAAS,GACTC,EAAU,EAEd,SAASC,EAAU,EAA+B,CAC1CH,IAAWI,IACf,EAASA,EACTC,IAAiBD,CAAI,EACzB,CAEA,SAASE,EAAK,EAAmB,EAA2B,CACxD,GAAIC,EAAgB,SAASC,CAAS,EAAG,OACzC,IAAMC,EAAUhB,EAAAA,YACZ,OAAOiB,EAAM,MAAS,SAAWA,EAAM,KAAO,GAC9C,oBACA,CAAE,SAAQ,eAAc,SAAQ,mBAAkB,CACtD,EACKD,EAAQ,WACbE,IAAY,CACR,MAAOH,EACP,KAAMC,EAAQ,KACd,GAAIC,EAAM,aAAe,IAAA,GACzB,IAAKA,CACT,CAAC,CACL,CAEA,SAASE,GAA0B,CAC/B,GAAIX,EAAQ,OACZ,GAAIF,GAAWc,EAAY,CACvBV,EAAU,OAAO,EACjB,MACJ,CACA,IAAMW,EAAQ,KAAK,IAAIC,EAAiB,GAAKhB,EAASiB,CAAU,EAChE,GAAW,EACX,EAAa,WAAWC,EAASH,CAAK,CAC1C,CAEA,SAASI,EAAQ,EAAsB,CACnCC,IAAaC,CAAK,EACbC,EAAeD,CAAK,GAIzBjB,EAAU,QAAQ,EAClBS,EAAkB,GAJdT,EAAU,OAAO,CAKzB,CAEA,SAASc,GAAgB,CACrB,GAAIhB,EAAQ,OAOZ,GANA,AAEIJ,KADAA,EAAO,MAAM,EACJ,MAEbM,EAAU,YAAY,EACtB,GAAW,EACP,OAAOmB,GAAQ,SAAU,CACzBC,EAAKD,CAAG,EACR,MACJ,CACA,IAAME,EAAUtB,EAChB,QAAQ,QAAQ,CAAC,CACZ,KAAKoB,CAAG,CAAC,CACT,KACI,GAAa,CACN,CAACrB,GAAUuB,IAAYtB,GAASqB,EAAKE,CAAQ,CACrD,EACC,GAAmB,CACZ,CAACxB,GAAUuB,IAAYtB,GAASgB,EAAQE,CAAK,CACrD,CACJ,CACR,CAEA,SAASG,EAAK,EAAsB,CAChC,IAAMG,EAAK,IAAI,YAAYC,EAAQ,CAAE,iBAAgB,CAAC,EACtD,EAASD,EAET,EAAG,WAAe,CACd,EAAU,EACVvB,EAAU,MAAM,EAChByB,IAAS,CACb,EAEA,EAAG,UAAa,GAAUtB,EAAK,UAAWI,CAAK,EAC/C,IAAK,IAAMmB,KAAQC,EACfJ,EAAG,iBAAiBG,EAAO,GAAUvB,EAAKuB,EAAMnB,CAAqB,CAAC,EAE1E,IAAK,IAAMmB,KAAQtB,EACfmB,EAAG,iBAAiBG,MAAY,CAEhC,CAAC,EAGL,EAAG,QAAW,GAAU,CACpBE,IAAUrB,CAAK,EACfgB,EAAG,MAAM,EACT,EAAS,KACTvB,EAAU,QAAQ,EAClBS,EAAkB,CACtB,CACJ,CAEA,SAASoB,GAAc,CACnB,EAAS,GACT,AAEIlC,KADA,aAAaA,CAAU,EACV,MAEjB,EAAU,EACV,AAEID,KADAA,EAAO,MAAM,EACJ,MAEbM,EAAU,QAAQ,CACtB,CAEA,SAAS8B,GAAkB,CACvB,AAEInC,KADA,aAAaA,CAAU,EACV,MAEjB,EAAU,EACV,EAAS,GACTmB,EAAQ,CACZ,CAIA,OAFAA,EAAQ,EAED,CACH,QACA,YACA,IAAI,QAAS,CACT,OAAOjB,CACX,CACJ,CACJ,C"}
|