@possibl/rcrt-sdk 0.1.1 → 0.1.3

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.
Files changed (87) hide show
  1. package/CHANGELOG.md +23 -67
  2. package/LICENSE +21 -0
  3. package/README.md +2 -20
  4. package/dist/auth.d.ts +45 -0
  5. package/dist/auth.d.ts.map +1 -0
  6. package/{src/auth.ts → dist/auth.js} +9 -24
  7. package/dist/auth.js.map +1 -0
  8. package/dist/authn.d.ts +84 -0
  9. package/dist/authn.d.ts.map +1 -0
  10. package/dist/authn.js +75 -0
  11. package/dist/authn.js.map +1 -0
  12. package/dist/breadcrumbs.d.ts +32 -0
  13. package/dist/breadcrumbs.d.ts.map +1 -0
  14. package/dist/breadcrumbs.js +96 -0
  15. package/dist/breadcrumbs.js.map +1 -0
  16. package/dist/cards.d.ts +28 -0
  17. package/dist/cards.d.ts.map +1 -0
  18. package/dist/cards.js +106 -0
  19. package/dist/cards.js.map +1 -0
  20. package/dist/chat.d.ts +50 -0
  21. package/dist/chat.d.ts.map +1 -0
  22. package/dist/chat.js +58 -0
  23. package/dist/chat.js.map +1 -0
  24. package/dist/client.d.ts +45 -0
  25. package/dist/client.d.ts.map +1 -0
  26. package/dist/client.js +69 -0
  27. package/dist/client.js.map +1 -0
  28. package/dist/errors.d.ts +32 -0
  29. package/dist/errors.d.ts.map +1 -0
  30. package/dist/errors.js +76 -0
  31. package/dist/errors.js.map +1 -0
  32. package/dist/generated/conformance.d.ts +48 -0
  33. package/dist/generated/conformance.d.ts.map +1 -0
  34. package/dist/generated/conformance.js +24 -0
  35. package/dist/generated/conformance.js.map +1 -0
  36. package/dist/generated/index.d.ts +34 -0
  37. package/dist/generated/index.d.ts.map +1 -0
  38. package/dist/generated/index.js +34 -0
  39. package/dist/generated/index.js.map +1 -0
  40. package/dist/generated/openapi.d.ts +3900 -0
  41. package/dist/generated/openapi.d.ts.map +1 -0
  42. package/dist/generated/openapi.js +6 -0
  43. package/dist/generated/openapi.js.map +1 -0
  44. package/dist/grants.d.ts +41 -0
  45. package/dist/grants.d.ts.map +1 -0
  46. package/dist/grants.js +50 -0
  47. package/dist/grants.js.map +1 -0
  48. package/dist/index.d.ts +23 -0
  49. package/dist/index.d.ts.map +1 -0
  50. package/dist/index.js +18 -0
  51. package/dist/index.js.map +1 -0
  52. package/dist/internal/fetch.d.ts +41 -0
  53. package/dist/internal/fetch.d.ts.map +1 -0
  54. package/dist/internal/fetch.js +106 -0
  55. package/dist/internal/fetch.js.map +1 -0
  56. package/dist/internal/sse.d.ts +82 -0
  57. package/dist/internal/sse.d.ts.map +1 -0
  58. package/dist/internal/sse.js +161 -0
  59. package/dist/internal/sse.js.map +1 -0
  60. package/dist/types/breadcrumb.d.ts +70 -0
  61. package/dist/types/breadcrumb.d.ts.map +1 -0
  62. package/dist/types/breadcrumb.js +8 -0
  63. package/dist/types/breadcrumb.js.map +1 -0
  64. package/dist/types/card.d.ts +251 -0
  65. package/dist/types/card.d.ts.map +1 -0
  66. package/dist/types/card.js +10 -0
  67. package/dist/types/card.js.map +1 -0
  68. package/dist/types/index.d.ts +3 -0
  69. package/dist/types/index.d.ts.map +1 -0
  70. package/{src/types/index.ts → dist/types/index.js} +1 -0
  71. package/dist/types/index.js.map +1 -0
  72. package/package.json +35 -6
  73. package/src/authn.ts +0 -159
  74. package/src/breadcrumbs.ts +0 -111
  75. package/src/capabilities.ts +0 -93
  76. package/src/cards.ts +0 -109
  77. package/src/chat.ts +0 -83
  78. package/src/client.ts +0 -97
  79. package/src/errors.ts +0 -101
  80. package/src/files.ts +0 -135
  81. package/src/grants.ts +0 -99
  82. package/src/index.ts +0 -103
  83. package/src/internal/fetch.ts +0 -133
  84. package/src/internal/sse.ts +0 -236
  85. package/src/sessions.ts +0 -110
  86. package/src/types/breadcrumb.ts +0 -77
  87. package/src/types/card.ts +0 -298
package/src/errors.ts DELETED
@@ -1,101 +0,0 @@
1
- /**
2
- * Error taxonomy.
3
- *
4
- * The RCRT backend is mid-migration from a flat `{"error": "string"}`
5
- * envelope to a typed `{"error": {"code", "message", "details"}}`
6
- * shape. This module normalises both into one `ApiError` that app
7
- * code can switch on.
8
- *
9
- * See `packages/docs/guides/07-error-handling.md`.
10
- */
11
-
12
- /** Stable machine-readable error codes recognised by the SDK. */
13
- export type KnownErrorCode =
14
- | 'UNAUTHORIZED'
15
- | 'FORBIDDEN'
16
- | 'NOT_FOUND'
17
- | 'CONFLICT'
18
- | 'INVALID_REQUEST'
19
- | 'TOO_MANY_REQUESTS'
20
- | 'INTERNAL'
21
- | 'SERVICE_UNAVAILABLE'
22
- | 'UNKNOWN';
23
-
24
- export interface ApiErrorDetail {
25
- /** Machine-readable code if the server sent one. Otherwise derived from status. */
26
- code: KnownErrorCode | string;
27
- message: string;
28
- details?: Record<string, unknown>;
29
- }
30
-
31
- export class ApiError extends Error {
32
- public readonly status: number;
33
- public readonly detail: ApiErrorDetail;
34
- public readonly rawBody: string | undefined;
35
-
36
- constructor(status: number, detail: ApiErrorDetail, rawBody?: string) {
37
- super(detail.message);
38
- this.name = 'ApiError';
39
- this.status = status;
40
- this.detail = detail;
41
- this.rawBody = rawBody;
42
- }
43
-
44
- static fromResponse(status: number, rawBody: string): ApiError {
45
- return new ApiError(status, parseErrorBody(status, rawBody), rawBody);
46
- }
47
- }
48
-
49
- /** Errors thrown by the SDK before we even reach the server. */
50
- export class SdkError extends Error {
51
- public readonly code: string;
52
-
53
- constructor(code: string, message: string) {
54
- super(message);
55
- this.name = 'SdkError';
56
- this.code = code;
57
- }
58
- }
59
-
60
- // ── Internal helpers ─────────────────────────────────────────────
61
-
62
- const STATUS_TO_CODE: Record<number, KnownErrorCode> = {
63
- 400: 'INVALID_REQUEST',
64
- 401: 'UNAUTHORIZED',
65
- 403: 'FORBIDDEN',
66
- 404: 'NOT_FOUND',
67
- 409: 'CONFLICT',
68
- 429: 'TOO_MANY_REQUESTS',
69
- 500: 'INTERNAL',
70
- 503: 'SERVICE_UNAVAILABLE',
71
- };
72
-
73
- export function parseErrorBody(status: number, rawBody: string): ApiErrorDetail {
74
- const fallbackCode = STATUS_TO_CODE[status] ?? 'UNKNOWN';
75
- if (!rawBody.trim()) {
76
- return { code: fallbackCode, message: `HTTP ${status}` };
77
- }
78
- try {
79
- const parsed = JSON.parse(rawBody) as unknown;
80
- if (parsed && typeof parsed === 'object' && 'error' in parsed) {
81
- const err = (parsed as { error: unknown }).error;
82
- if (typeof err === 'string') {
83
- return { code: fallbackCode, message: err };
84
- }
85
- if (err && typeof err === 'object') {
86
- const e = err as Record<string, unknown>;
87
- const detail: ApiErrorDetail = {
88
- code: typeof e.code === 'string' ? e.code : fallbackCode,
89
- message: typeof e.message === 'string' ? e.message : `HTTP ${status}`,
90
- };
91
- if (typeof e.details === 'object' && e.details !== null) {
92
- detail.details = e.details as Record<string, unknown>;
93
- }
94
- return detail;
95
- }
96
- }
97
- return { code: fallbackCode, message: rawBody.slice(0, 200) };
98
- } catch {
99
- return { code: fallbackCode, message: rawBody.slice(0, 200) };
100
- }
101
- }
package/src/files.ts DELETED
@@ -1,135 +0,0 @@
1
- /**
2
- * Files module — upload + download + read text + list + delete.
3
- *
4
- * Files are stored as breadcrumbs under `interpret:file` so they
5
- * inherit the same tag / permissions / SSE lifecycle as everything
6
- * else. The upload returns the file's breadcrumb so callers can
7
- * round-trip into `breadcrumbs.update()` for renames or tagging.
8
- *
9
- * `getDownloadUrl` returns a short-lived signed URL suitable for
10
- * `<a href>` or `fetch()`; the SDK does not cache it.
11
- *
12
- * See `packages/docs/guides/06-files.md` for the narrative.
13
- */
14
-
15
- import type { FetchContext } from './internal/fetch.js';
16
- import { request } from './internal/fetch.js';
17
- import { ApiError, SdkError } from './errors.js';
18
- import type { Breadcrumb } from './types/breadcrumb.js';
19
-
20
- export type FileScope = 'tenant' | 'org' | 'user';
21
-
22
- export interface UploadFileOptions {
23
- /** Default `'tenant'` — file is visible to all members of the active workspace. */
24
- scope?: FileScope;
25
- /** Override the filename written on the breadcrumb (defaults to `File.name`). */
26
- filename?: string;
27
- /** Extra tags to attach. `interpret:file` is always added by the server. */
28
- tags?: string[];
29
- }
30
-
31
- export interface ListFilesOptions {
32
- scope?: FileScope;
33
- limit?: number;
34
- }
35
-
36
- export class FilesModule {
37
- constructor(private readonly ctx: FetchContext) {}
38
-
39
- /**
40
- * `POST /v1/files` — multipart upload.
41
- *
42
- * Accepts a `Blob` (browser / RN) or `File` (browser only). Server
43
- * returns the breadcrumb wrapping the stored file.
44
- */
45
- async upload(file: Blob | File, options: UploadFileOptions = {}): Promise<Breadcrumb> {
46
- const fetchImpl = this.ctx.fetchImpl ?? globalThis.fetch;
47
- if (typeof fetchImpl !== 'function') {
48
- throw new SdkError('NO_FETCH', 'fetch() is not available — pass `fetchImpl` in client config');
49
- }
50
- if (typeof FormData === 'undefined') {
51
- throw new SdkError(
52
- 'NO_FORMDATA',
53
- 'FormData is not available in this environment — provide a polyfill (e.g. form-data on Node) before calling files.upload()',
54
- );
55
- }
56
-
57
- const formData = new FormData();
58
- const filename =
59
- options.filename ?? ((file as File).name ? (file as File).name : 'upload');
60
- formData.append('file', file as Blob, filename);
61
- formData.append('scope', options.scope ?? 'tenant');
62
- if (options.tags?.length) {
63
- formData.append('tags', options.tags.join(','));
64
- }
65
-
66
- const url = `${this.ctx.apiUrl.replace(/\/$/, '')}/v1/files`;
67
- const idToken = await this.ctx.tokenProvider.getIdToken();
68
- const headers: Record<string, string> = {
69
- Authorization: `Bearer ${idToken}`,
70
- };
71
- if (this.ctx.tenantId) headers['X-Tenant-ID'] = this.ctx.tenantId;
72
-
73
- const res = await fetchImpl(url, { method: 'POST', headers, body: formData });
74
- if (!res.ok) {
75
- const raw = await res.text().catch(() => '');
76
- throw ApiError.fromResponse(res.status, raw || 'upload failed');
77
- }
78
- const json = (await res.json()) as { breadcrumb?: Breadcrumb } & Breadcrumb;
79
- return json.breadcrumb ?? json;
80
- }
81
-
82
- /** `GET /v1/files?scope=...&limit=...` */
83
- async list(options: ListFilesOptions = {}): Promise<Breadcrumb[]> {
84
- const res = await request<{ files: Breadcrumb[] } | Breadcrumb[]>(this.ctx, '/v1/files', {
85
- query: {
86
- scope: options.scope,
87
- limit: options.limit,
88
- },
89
- });
90
- return Array.isArray(res) ? res : (res.files ?? []);
91
- }
92
-
93
- /** `GET /v1/files/{id}` — wrapping breadcrumb for the file. */
94
- async get(fileId: string): Promise<Breadcrumb> {
95
- const res = await request<{ file: Breadcrumb } | Breadcrumb>(this.ctx, `/v1/files/${fileId}`);
96
- return 'file' in (res as { file: Breadcrumb })
97
- ? (res as { file: Breadcrumb }).file
98
- : (res as Breadcrumb);
99
- }
100
-
101
- /**
102
- * `GET /v1/files/{id}/content?expiry=...` — signed download URL.
103
- *
104
- * Default expiry `1h`. URL format goes through Cloud Storage's V4
105
- * signed URL flow on the server side. Don't cache — re-fetch when
106
- * you need a fresh one.
107
- */
108
- async getDownloadUrl(fileId: string, expiry: string = '1h'): Promise<string> {
109
- const res = await request<{ download_url: string }>(this.ctx, `/v1/files/${fileId}/content`, {
110
- query: { expiry },
111
- });
112
- return res.download_url;
113
- }
114
-
115
- /**
116
- * `GET /v1/files/{id}/text` — server-side OCR / text extraction.
117
- *
118
- * Only works for content the document-parser knows about (PDF,
119
- * common image formats, plain text). Returns `''` for unparseable.
120
- */
121
- async getText(fileId: string): Promise<string> {
122
- const res = await request<{ text: string }>(this.ctx, `/v1/files/${fileId}/text`);
123
- return res.text;
124
- }
125
-
126
- /** `DELETE /v1/files/{id}` — soft delete on the wrapping breadcrumb. */
127
- async delete(fileId: string): Promise<void> {
128
- try {
129
- await request<void>(this.ctx, `/v1/files/${fileId}`, { method: 'DELETE' });
130
- } catch (err) {
131
- if (err instanceof ApiError && err.status === 404) return; // idempotent
132
- throw err;
133
- }
134
- }
135
- }
package/src/grants.ts DELETED
@@ -1,99 +0,0 @@
1
- /**
2
- * Service grants module — OAuth connect flow + grant management.
3
- *
4
- * See `packages/docs/guides/05-connecting-services.md`.
5
- */
6
-
7
- import type { FetchContext } from './internal/fetch.js';
8
- import { request } from './internal/fetch.js';
9
-
10
- export type GrantType = 'per_user' | 'service_account';
11
- export type GrantStatus = 'connected' | 'revoked' | 'error';
12
-
13
- export interface ServiceGrantSummary {
14
- id: string;
15
- service_id: string;
16
- account_label?: string | null;
17
- email?: string | null;
18
- scopes: string[];
19
- status: GrantStatus;
20
- granted_at: string;
21
- }
22
-
23
- export interface InitiateAuthRequest {
24
- grant_type?: GrantType;
25
- scopes?: string[];
26
- /** Where the browser lands after provider consent. */
27
- redirect_uri?: string;
28
- /** Label this grant as a specific account (e.g. `work`, `personal`). */
29
- account_label?: string;
30
- }
31
-
32
- export interface InitiateAuthResponse {
33
- auth_url: string;
34
- }
35
-
36
- export class GrantsModule {
37
- constructor(private readonly ctx: FetchContext) {}
38
-
39
- /** All grants the current workspace can see. */
40
- async list(): Promise<ServiceGrantSummary[]> {
41
- const res = await request<ServiceGrantSummary[] | { grants: ServiceGrantSummary[] }>(
42
- this.ctx,
43
- '/v1/service-grants',
44
- );
45
- return Array.isArray(res) ? res : (res.grants ?? []);
46
- }
47
-
48
- /** Grant for one specific service (optionally an account label). */
49
- async get(serviceId: string, accountLabel?: string): Promise<ServiceGrantSummary | null> {
50
- const query: Record<string, string | undefined> = {};
51
- if (accountLabel) query.account = accountLabel;
52
- const res = await request<ServiceGrantSummary | null>(this.ctx, `/v1/service-grants/${serviceId}`, {
53
- query,
54
- });
55
- return res ?? null;
56
- }
57
-
58
- /** Kick off an OAuth flow. Returns the provider URL to open in a popup / in-app browser. */
59
- async initiateAuth(serviceId: string, req: InitiateAuthRequest = {}): Promise<InitiateAuthResponse> {
60
- return request<InitiateAuthResponse>(this.ctx, `/v1/service-grants/${serviceId}/auth/init`, {
61
- method: 'POST',
62
- body: {
63
- grant_type: req.grant_type ?? 'per_user',
64
- scopes: req.scopes,
65
- redirect_uri: req.redirect_uri,
66
- account_label: req.account_label,
67
- },
68
- });
69
- }
70
-
71
- /** Revoke a grant. Safe + idempotent — revoking a revoked grant is a no-op. */
72
- async revoke(serviceId: string, accountLabel?: string): Promise<void> {
73
- const query: Record<string, string | undefined> = {};
74
- if (accountLabel) query.account = accountLabel;
75
- await request<void>(this.ctx, `/v1/service-grants/${serviceId}`, {
76
- method: 'DELETE',
77
- query,
78
- });
79
- }
80
-
81
- /**
82
- * `GET /v1/services/{name}/resolve` — server-side credential resolution.
83
- *
84
- * Used by tools that need to call a third-party API on behalf of
85
- * the workspace. The server hands back the live credentials for the
86
- * requested service (Gmail, Notion etc.) keyed off the active grant.
87
- *
88
- * Returns `{ service, credentials }`. Treat the credentials as
89
- * sensitive — don't log them.
90
- */
91
- async resolveService(
92
- name: string,
93
- ): Promise<{ service: string; credentials: Record<string, unknown> }> {
94
- return request<{ service: string; credentials: Record<string, unknown> }>(
95
- this.ctx,
96
- `/v1/services/${name}/resolve`,
97
- );
98
- }
99
- }
package/src/index.ts DELETED
@@ -1,103 +0,0 @@
1
- /**
2
- * @possibl/rcrt-sdk — TypeScript SDK for RCRT.
3
- *
4
- * Public surface. Anything not re-exported here is internal.
5
- */
6
-
7
- // Primary entry
8
- export { RcrtClient } from './client.js';
9
- export type { RcrtClientConfig } from './client.js';
10
-
11
- // Auth
12
- export { staticTokenProvider } from './auth.js';
13
- export type { TokenProvider } from './auth.js';
14
-
15
- // Errors
16
- export { ApiError, SdkError } from './errors.js';
17
- export type { ApiErrorDetail, KnownErrorCode } from './errors.js';
18
-
19
- // Modules (handy if consumers want the types directly)
20
- export { BreadcrumbsModule } from './breadcrumbs.js';
21
- export { ChatModule } from './chat.js';
22
- export { CardsModule } from './cards.js';
23
- export { GrantsModule } from './grants.js';
24
- export { IdentityModule } from './authn.js';
25
- export { FilesModule } from './files.js';
26
- export { SessionsModule } from './sessions.js';
27
- export { CapabilitiesModule } from './capabilities.js';
28
- export type {
29
- MeResponse,
30
- Tenant,
31
- PendingInvitation,
32
- UserProfileBreadcrumb,
33
- } from './authn.js';
34
- export type { SendChatRequest, SendChatResponse, SseStreamOptions } from './chat.js';
35
- export type {
36
- ServiceGrantSummary,
37
- InitiateAuthRequest,
38
- InitiateAuthResponse,
39
- GrantType,
40
- GrantStatus,
41
- } from './grants.js';
42
- export type { FileScope, UploadFileOptions, ListFilesOptions } from './files.js';
43
- export type {
44
- SessionParticipant,
45
- ConstellationData,
46
- ConstellationNode,
47
- ConstellationEdge,
48
- } from './sessions.js';
49
- export type { CapabilityType, ChattableAgent } from './capabilities.js';
50
-
51
- // Types — breadcrumbs
52
- export type {
53
- Actor,
54
- ActorType,
55
- Breadcrumb,
56
- BreadcrumbResponse,
57
- CreateBreadcrumbRequest,
58
- UpdateBreadcrumbRequest,
59
- QueryByTagsOptions,
60
- SemanticSearchOptions,
61
- } from './types/breadcrumb.js';
62
-
63
- // Types — JIT UI cards
64
- export type {
65
- Card,
66
- CardLayout,
67
- CardHeader,
68
- CardBody,
69
- CardFooter,
70
- CardAction,
71
- ActionStyle,
72
- Row,
73
- TextRow,
74
- FlexibleTextRow,
75
- ClaimRow,
76
- MetricRow,
77
- EventRow,
78
- PersonRow,
79
- PlaceRow,
80
- ToggleRow,
81
- DraftPreviewRow,
82
- ChartRow,
83
- ChartSpec,
84
- TrendDirection,
85
- DraftPreview,
86
- InputSpec,
87
- RatingSpec,
88
- ProgressState,
89
- ConnectDetails,
90
- CompareColumn,
91
- ResolveRequest,
92
- CanonicalStatus,
93
- } from './types/card.js';
94
-
95
- // SSE primitives (useful for custom consumers)
96
- export type {
97
- SseConnection,
98
- SseHandlers,
99
- SseDelta,
100
- SseThought,
101
- SseAction,
102
- SseError,
103
- } from './internal/sse.js';
@@ -1,133 +0,0 @@
1
- /**
2
- * Internal HTTP helper. Handles:
3
- * - auth + tenant headers
4
- * - JSON encoding + parsing
5
- * - error envelope normalisation
6
- * - 409 retry for optimistic-locking PATCHes
7
- * - 401 one-shot retry after TokenProvider.onUnauthorized
8
- * - 429 honouring Retry-After
9
- *
10
- * Not exported from the public surface — use the RcrtClient methods.
11
- */
12
-
13
- import { ApiError, parseErrorBody, SdkError } from '../errors.js';
14
- import type { TokenProvider } from '../auth.js';
15
-
16
- export interface FetchContext {
17
- apiUrl: string;
18
- tenantId: string | null;
19
- tokenProvider: TokenProvider;
20
- fetchImpl?: typeof fetch;
21
- }
22
-
23
- export interface RequestOptions {
24
- method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
25
- /** JSON body; will be serialised. */
26
- body?: unknown;
27
- /** Extra query params. */
28
- query?: Record<string, string | number | boolean | undefined | null>;
29
- /** Extra headers. */
30
- headers?: Record<string, string>;
31
- /** Skip tenant header (identity routes). */
32
- skipTenant?: boolean;
33
- /** Abort signal. */
34
- signal?: AbortSignal;
35
- /**
36
- * Retries for 409 Conflict. Each retry re-fetches first if the
37
- * caller provides a `refetchBeforeRetry` hook.
38
- */
39
- maxConflictRetries?: number;
40
- refetchBeforeRetry?: () => Promise<Partial<{ body: unknown }>>;
41
- }
42
-
43
- export async function request<T = unknown>(
44
- ctx: FetchContext,
45
- path: string,
46
- options: RequestOptions = {},
47
- ): Promise<T> {
48
- const method = options.method ?? 'GET';
49
- const fetchImpl = ctx.fetchImpl ?? globalThis.fetch;
50
- if (typeof fetchImpl !== 'function') {
51
- throw new SdkError('NO_FETCH', 'fetch() is not available — pass `fetchImpl` in client config');
52
- }
53
-
54
- const url = buildUrl(ctx.apiUrl, path, options.query);
55
- const maxRetries = options.maxConflictRetries ?? 2;
56
- let attempt = 0;
57
- let body = options.body;
58
- let didRefreshAuth = false;
59
-
60
- while (true) {
61
- const idToken = await ctx.tokenProvider.getIdToken();
62
- const headers: Record<string, string> = {
63
- 'Authorization': `Bearer ${idToken}`,
64
- ...(options.headers ?? {}),
65
- };
66
- if (!options.skipTenant && ctx.tenantId) {
67
- headers['X-Tenant-ID'] = ctx.tenantId;
68
- }
69
- if (body !== undefined) {
70
- headers['Content-Type'] = 'application/json';
71
- }
72
-
73
- const init: RequestInit = { method, headers };
74
- if (body !== undefined) init.body = JSON.stringify(body);
75
- if (options.signal) init.signal = options.signal;
76
- const res = await fetchImpl(url, init);
77
-
78
- if (res.status >= 200 && res.status < 300) {
79
- if (res.status === 204) return undefined as T;
80
- const text = await res.text();
81
- if (!text) return undefined as T;
82
- try {
83
- return JSON.parse(text) as T;
84
- } catch {
85
- // Some endpoints return non-JSON (e.g. raw bytes); surface as a string.
86
- return text as unknown as T;
87
- }
88
- }
89
-
90
- // Retry 401 once after the token provider has a chance to refresh.
91
- if (res.status === 401 && !didRefreshAuth && ctx.tokenProvider.onUnauthorized) {
92
- didRefreshAuth = true;
93
- await ctx.tokenProvider.onUnauthorized();
94
- continue;
95
- }
96
-
97
- // Retry 409 with a fresh body if the caller supports optimistic refetch.
98
- if (res.status === 409 && attempt < maxRetries && options.refetchBeforeRetry) {
99
- attempt += 1;
100
- const fresh = await options.refetchBeforeRetry();
101
- if (fresh.body !== undefined) body = fresh.body;
102
- continue;
103
- }
104
-
105
- // Retry 429 honouring Retry-After, one time.
106
- if (res.status === 429 && attempt < 1) {
107
- attempt += 1;
108
- const ra = parseInt(res.headers.get('Retry-After') ?? '1', 10);
109
- await sleep((isNaN(ra) ? 1 : ra) * 1000);
110
- continue;
111
- }
112
-
113
- const raw = await res.text().catch(() => '');
114
- throw new ApiError(res.status, parseErrorBody(res.status, raw), raw);
115
- }
116
- }
117
-
118
- function buildUrl(apiUrl: string, path: string, query?: RequestOptions['query']): string {
119
- const base = apiUrl.replace(/\/$/, '');
120
- const fullPath = path.startsWith('/') ? path : `/${path}`;
121
- const u = new URL(base + fullPath);
122
- if (query) {
123
- for (const [k, v] of Object.entries(query)) {
124
- if (v === undefined || v === null) continue;
125
- u.searchParams.set(k, String(v));
126
- }
127
- }
128
- return u.toString();
129
- }
130
-
131
- function sleep(ms: number): Promise<void> {
132
- return new Promise((r) => setTimeout(r, ms));
133
- }