@parall/sdk 1.56.0 → 1.56.2

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.
@@ -0,0 +1,87 @@
1
+ export function createWikiUploadFormData(params) {
2
+ const form = new FormData();
3
+ form.append('path', params.path);
4
+ form.append('file', params.file);
5
+ if (params.message)
6
+ form.append('message', params.message);
7
+ if (params.uploadId)
8
+ form.append('upload_id', params.uploadId);
9
+ if (params.conflict)
10
+ form.append('conflict', params.conflict);
11
+ if ('contentRoute' in params && params.contentRoute) {
12
+ form.append('content_route', params.contentRoute);
13
+ }
14
+ return form;
15
+ }
16
+ /** Send one multipart request. XHR is used only when a browser caller asks
17
+ * for upload progress; fetch remains the transport everywhere else. Auth
18
+ * refresh and API error decoding stay in ParallClient above this boundary. */
19
+ export function sendMultipartRequest(options) {
20
+ if (options.onProgress && typeof XMLHttpRequest !== 'undefined') {
21
+ return multipartXHR(options, options.onProgress);
22
+ }
23
+ const timeoutSignal = AbortSignal.timeout(options.timeoutMs);
24
+ const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal;
25
+ return fetch(options.url, {
26
+ method: options.method,
27
+ headers: options.headers,
28
+ body: options.body,
29
+ signal,
30
+ });
31
+ }
32
+ function multipartXHR(options, onProgress) {
33
+ return new Promise((resolve, reject) => {
34
+ const xhr = new XMLHttpRequest();
35
+ let settled = false;
36
+ const finish = (fn) => {
37
+ if (settled)
38
+ return;
39
+ settled = true;
40
+ options.signal?.removeEventListener('abort', abortFromSignal);
41
+ fn();
42
+ };
43
+ const abortFromSignal = () => xhr.abort();
44
+ xhr.open(options.method, options.url, true);
45
+ xhr.timeout = options.timeoutMs;
46
+ for (const [name, value] of Object.entries(options.headers)) {
47
+ xhr.setRequestHeader(name, value);
48
+ }
49
+ xhr.upload.onprogress = (event) => {
50
+ try {
51
+ onProgress(event.loaded, event.lengthComputable ? event.total : 0);
52
+ }
53
+ catch {
54
+ // Observer failures must not cancel an otherwise healthy upload.
55
+ }
56
+ };
57
+ xhr.onload = () => finish(() => {
58
+ const responseHeaders = new Headers();
59
+ for (const line of xhr
60
+ .getAllResponseHeaders()
61
+ .trim()
62
+ .split(/[\r\n]+/)) {
63
+ if (!line)
64
+ continue;
65
+ const separator = line.indexOf(':');
66
+ if (separator > 0) {
67
+ responseHeaders.append(line.slice(0, separator).trim(), line.slice(separator + 1).trim());
68
+ }
69
+ }
70
+ const responseBody = xhr.status === 204 || xhr.responseText === '' ? null : xhr.responseText;
71
+ resolve(new Response(responseBody, {
72
+ status: xhr.status,
73
+ statusText: xhr.statusText,
74
+ headers: responseHeaders,
75
+ }));
76
+ });
77
+ xhr.onerror = () => finish(() => reject(new TypeError('Network request failed')));
78
+ xhr.ontimeout = () => finish(() => reject(new DOMException('Request timed out', 'TimeoutError')));
79
+ xhr.onabort = () => finish(() => reject(new DOMException('Request aborted', 'AbortError')));
80
+ if (options.signal?.aborted) {
81
+ finish(() => reject(new DOMException('Request aborted', 'AbortError')));
82
+ return;
83
+ }
84
+ options.signal?.addEventListener('abort', abortFromSignal, { once: true });
85
+ xhr.send(options.body);
86
+ });
87
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parall/sdk",
3
- "version": "1.56.0",
3
+ "version": "1.56.2",
4
4
  "description": "TypeScript client SDK for Parall — REST + WebSocket client, shared types",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/client.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  import { type BrowserViewerRequestOptions, browserViewerRequestOptions } from './browser-viewer.js';
2
2
  import { API_BASE, ENDPOINTS, WIKI_BASE } from './constants.js';
3
3
  import { AttachmentClient } from './attachment-client.js';
4
+ import {
5
+ createWikiUploadFormData,
6
+ sendMultipartRequest,
7
+ type WikiChangesetFileUploadParams,
8
+ type WikiFileUploadParams,
9
+ type WikiFileUploadResponse,
10
+ } from './wiki-upload.js';
11
+ import { normalizeWikiChangeset } from './wiki-changeset.js';
4
12
  import type {
5
13
  AddTeamMemberRequest,
6
14
  AgentClip,
@@ -94,6 +102,7 @@ import type {
94
102
  CreateCommentRequest,
95
103
  CreateExternalConnectionInput,
96
104
  CreateExternalTriggerInput,
105
+ CreatePersonalApiKeyRequest,
97
106
  CreateScheduleInput,
98
107
  CreateSetupIntentResponse,
99
108
  CreateTeamRequest,
@@ -176,6 +185,7 @@ import type {
176
185
  OutboundRefsRequest,
177
186
  OutboundRefsResponse,
178
187
  PaginatedResponse,
188
+ PersonalApiKey,
179
189
  PlatformConfigResponse,
180
190
  PlatformModelsResponse,
181
191
  PublishRegistryClipRequest,
@@ -262,7 +272,6 @@ import type {
262
272
  WikiCommit,
263
273
  WikiDiff,
264
274
  WikiFilePreviewUrlResponse,
265
- WikiFileUploadResponse,
266
275
  WikiNodeSectionArtifact,
267
276
  WikiOperation,
268
277
  WikiOperationsResponse,
@@ -551,7 +560,7 @@ export class ParallClient extends AttachmentClient {
551
560
  * Multipart upload variant of `request`. Same auth / refresh / error
552
561
  * handling, but lets the caller hand us a prepared `FormData` (file +
553
562
  * text fields) and skips the JSON content-type. Used by
554
- * uploadWikiFile / uploadWikiFileToChangeset — wiki binary uploads can
563
+ * uploadWikiFile / uploadWikiFileToChangeset — wiki file uploads can
555
564
  * hit the 100 MiB cap, so a longer 5-minute timeout is used so a
556
565
  * 50 MiB blob on a slow connection doesn't get chopped at 15 s.
557
566
  */
@@ -560,6 +569,11 @@ export class ParallClient extends AttachmentClient {
560
569
  path: string,
561
570
  body: FormData,
562
571
  retried = false,
572
+ opts?: {
573
+ signal?: AbortSignal;
574
+ onProgress?: (uploadedBytes: number, totalBytes: number) => void;
575
+ timeoutMs?: number;
576
+ },
563
577
  ): Promise<T> {
564
578
  if (!retried) {
565
579
  await this.ensureFreshToken(path);
@@ -568,13 +582,17 @@ export class ParallClient extends AttachmentClient {
568
582
  const { 'Content-Type': _drop, ...headers } = this.buildHeaders(path);
569
583
  void _drop;
570
584
 
585
+ const timeoutMs = opts?.timeoutMs ?? 5 * 60 * 1000;
571
586
  let res: Response;
572
587
  try {
573
- res = await fetch(`${this.baseUrlFor(path)}${path}`, {
588
+ res = await sendMultipartRequest({
574
589
  method,
590
+ url: `${this.baseUrlFor(path)}${path}`,
575
591
  headers,
576
592
  body,
577
- signal: AbortSignal.timeout(5 * 60 * 1000),
593
+ timeoutMs,
594
+ signal: opts?.signal,
595
+ onProgress: opts?.onProgress,
578
596
  });
579
597
  } catch (err) {
580
598
  throw ParallClient.normalizeFetchError(err);
@@ -586,7 +604,7 @@ export class ParallClient extends AttachmentClient {
586
604
  if (!retried && !isAuthPath && this.getRefreshToken) {
587
605
  const refreshed = await this.tryRefresh();
588
606
  if (refreshed) {
589
- return this.multipartRequest<T>(method, path, body, true);
607
+ return this.multipartRequest<T>(method, path, body, true, opts);
590
608
  }
591
609
  }
592
610
  if (this.onTokenExpired && !isAuthPath) {
@@ -687,6 +705,22 @@ export class ParallClient extends AttachmentClient {
687
705
  return this.request('DELETE', ENDPOINTS.USER_AVATAR);
688
706
  }
689
707
 
708
+ // ---- Personal API keys (org-scoped; JWT session required) ----
709
+
710
+ /** Mints an org-scoped personal API key. The plaintext is returned once and never again. */
711
+ async createPersonalApiKey(req: CreatePersonalApiKeyRequest): Promise<ApiKey> {
712
+ return this.request('POST', ENDPOINTS.PERSONAL_API_KEYS, req);
713
+ }
714
+
715
+ async listPersonalApiKeys(): Promise<PersonalApiKey[]> {
716
+ const res = await this.request<{ data: PersonalApiKey[] }>('GET', ENDPOINTS.PERSONAL_API_KEYS);
717
+ return res.data;
718
+ }
719
+
720
+ async revokePersonalApiKey(keyId: string): Promise<void> {
721
+ return this.request('DELETE', ENDPOINTS.PERSONAL_API_KEY(keyId));
722
+ }
723
+
690
724
  async uploadAgentAvatar(
691
725
  orgId: string,
692
726
  agentId: string,
@@ -2923,39 +2957,38 @@ export class ParallClient extends AttachmentClient {
2923
2957
  async uploadWikiFile(
2924
2958
  orgId: string,
2925
2959
  wikiId: string,
2926
- params: { path: string; file: Blob; message?: string },
2960
+ params: WikiFileUploadParams,
2927
2961
  ): Promise<WikiFileUploadResponse> {
2928
2962
  // `POST /uploads` always writes to the wiki's default branch — the
2929
- // backend's parseUpload silently drops any `parent_ref` field, so
2963
+ // backend's parseUpload does not accept a `parent_ref` field, so
2930
2964
  // surfacing one in this signature would mislead callers. To target
2931
2965
  // a feature branch, use uploadWikiFileToChangeset.
2932
- const fd = new FormData();
2933
- fd.append('path', params.path);
2934
- fd.append('file', params.file);
2935
- if (params.message) fd.append('message', params.message);
2936
- return this.multipartRequest('POST', ENDPOINTS.WIKI_UPLOADS(orgId, wikiId), fd);
2966
+ const fd = createWikiUploadFormData(params);
2967
+ return this.multipartRequest('POST', ENDPOINTS.WIKI_UPLOADS(orgId, wikiId), fd, false, {
2968
+ signal: params.signal,
2969
+ onProgress: params.onProgress,
2970
+ });
2937
2971
  }
2938
2972
 
2939
2973
  /**
2940
- * Upload a binary file into a changeset's feature branch (read scope +
2941
- * author-only). Used by reader flow: propose a changeset, attach
2942
- * binary, PATCH markdown that references it. On merge the binary
2943
- * squashes into the default branch.
2974
+ * Upload a file into a changeset's feature branch (read scope +
2975
+ * author-only). The default remains binary-only; contentRoute `auto`
2976
+ * opts into reviewable text for the Web library upload flow. On merge the
2977
+ * branch contents squash into the default branch.
2944
2978
  */
2945
2979
  async uploadWikiFileToChangeset(
2946
2980
  orgId: string,
2947
2981
  wikiId: string,
2948
2982
  changesetId: string,
2949
- params: { path: string; file: Blob; message?: string },
2983
+ params: WikiChangesetFileUploadParams,
2950
2984
  ): Promise<WikiFileUploadResponse> {
2951
- const fd = new FormData();
2952
- fd.append('path', params.path);
2953
- fd.append('file', params.file);
2954
- if (params.message) fd.append('message', params.message);
2985
+ const fd = createWikiUploadFormData(params);
2955
2986
  return this.multipartRequest(
2956
2987
  'POST',
2957
2988
  ENDPOINTS.WIKI_CHANGESET_FILES(orgId, wikiId, changesetId),
2958
2989
  fd,
2990
+ false,
2991
+ { signal: params.signal, onProgress: params.onProgress },
2959
2992
  );
2960
2993
  }
2961
2994
 
@@ -4405,14 +4438,6 @@ export class ParallClient extends AttachmentClient {
4405
4438
  }
4406
4439
  }
4407
4440
 
4408
- function normalizeWikiChangeset(changeset: WikiChangeset): WikiChangeset {
4409
- return {
4410
- ...changeset,
4411
- changed_paths: changeset.changed_paths ?? [],
4412
- file_changes: changeset.file_changes ?? [],
4413
- };
4414
- }
4415
-
4416
4441
  export class ApiError extends Error {
4417
4442
  extras?: Record<string, unknown>;
4418
4443
  /** Retry-After delta seconds when the server supplies one. */
package/src/constants.ts CHANGED
@@ -361,6 +361,8 @@ export const ENDPOINTS = {
361
361
  // Users
362
362
  USERS_ME: `${API_BASE}/users/me`,
363
363
  USER_AVATAR: `${API_BASE}/users/me/avatar`,
364
+ PERSONAL_API_KEYS: `${API_BASE}/users/me/api-keys`,
365
+ PERSONAL_API_KEY: (keyId: string) => `${API_BASE}/users/me/api-keys/${keyId}`,
364
366
  USER: (id: string) => `${API_BASE}/users/${id}`,
365
367
 
366
368
  // WebSocket ticket
package/src/index.ts CHANGED
@@ -7,6 +7,12 @@ export * from './subject.js';
7
7
  export * from './task-label-types.js';
8
8
  export * from './types.js';
9
9
  export * from './wechat-types.js';
10
+ export type {
11
+ WikiChangesetFileUploadParams,
12
+ WikiFileUploadParams,
13
+ WikiFileUploadResponse,
14
+ WikiStoredAs,
15
+ } from './wiki-upload.js';
10
16
  export type {
11
17
  ParallWsOptions,
12
18
  WsClientEventMap,
package/src/types.ts CHANGED
@@ -4,6 +4,7 @@ import type {
4
4
  LabelDeletedData,
5
5
  LabelUpdatedData,
6
6
  } from './task-label-types.js';
7
+
7
8
  export type {
8
9
  CompletedUploadPart,
9
10
  FileUrlResponse,
@@ -63,7 +64,11 @@ export interface Chat {
63
64
  created_at: string;
64
65
  updated_at: string;
65
66
  archived_at?: string | null;
67
+ archived_by?: string | null;
66
68
  deleted_at?: string | null;
69
+ deleted_by?: string | null;
70
+ member_count?: number;
71
+ my_role?: ChatMemberRole;
67
72
  pinned: boolean;
68
73
  hidden: boolean;
69
74
  my_notification_level?: NotificationLevel;
@@ -705,11 +710,35 @@ export interface CreateInvitationRequest {
705
710
  role?: OrgMemberRole;
706
711
  }
707
712
 
708
- // Response of agent API key create/regenerate. The plaintext `api_key` is
709
- // returned exactly once at mint time and can never be fetched again.
713
+ // Response of API key create/regenerate (agent keys and personal keys alike).
714
+ // The plaintext `api_key` is returned exactly once at mint time and can never
715
+ // be fetched again. Personal keys additionally echo the org they are bound to.
710
716
  export interface ApiKey {
711
717
  id: string;
712
718
  api_key: string;
719
+ org_id?: string;
720
+ }
721
+
722
+ // A personal API key row as listed by GET /users/me/api-keys. Personal keys
723
+ // are org-scoped ("org_full"): the user's permissions inside org_id only.
724
+ // Managed Clip keys have their own surface; the key hash never leaves the
725
+ // server.
726
+ export interface PersonalApiKey {
727
+ id: string;
728
+ user_id: string;
729
+ name?: string;
730
+ org_id: string;
731
+ last_used_at?: string;
732
+ expires_at?: string;
733
+ created_at: string;
734
+ }
735
+
736
+ export interface CreatePersonalApiKeyRequest {
737
+ name?: string;
738
+ /** The org this key is bound to; the caller must be a member. */
739
+ org_id: string;
740
+ /** RFC3339; must be in the future. Omit for a non-expiring key. */
741
+ expires_at?: string;
713
742
  }
714
743
 
715
744
  export interface CreateAgentResponse {
@@ -1644,7 +1673,9 @@ export interface Task {
1644
1673
  completed_at: string | null;
1645
1674
  canceled_at: string | null;
1646
1675
  archived_at?: string | null;
1676
+ archived_by?: string | null;
1647
1677
  deleted_at?: string | null;
1678
+ deleted_by?: string | null;
1648
1679
  /**
1649
1680
  * Per-viewer: whether the requesting user may manage OTHER members' comment
1650
1681
  * subscriptions for this task (creator / assignee / project manager / org
@@ -1841,6 +1872,8 @@ export interface Project {
1841
1872
  sort_order: number;
1842
1873
  created_at: string;
1843
1874
  updated_at: string;
1875
+ archived_at?: string | null;
1876
+ archived_by?: string | null;
1844
1877
  /** The requesting user's own role, computed per request rather than stored.
1845
1878
  * Absent means they hold no membership (direct or team-derived). */
1846
1879
  my_role?: ProjectRole;
@@ -2382,6 +2415,10 @@ export interface WikiChangeset {
2382
2415
  title: string;
2383
2416
  message: string | null;
2384
2417
  status: WikiChangesetStatus;
2418
+ /** Present on current servers; optional keeps older mocked/client data source-compatible. */
2419
+ head_branch?: string | null;
2420
+ /** Present on current servers; optional keeps older mocked/client data source-compatible. */
2421
+ base_commit?: string | null;
2385
2422
  merge_commit: string | null;
2386
2423
  file_changes: WikiFileChange[];
2387
2424
  changed_paths: string[];
@@ -2649,6 +2686,8 @@ export interface CreateWikiChangesetRequest {
2649
2686
  title: string;
2650
2687
  message?: string;
2651
2688
  file_changes: WikiFileChangeInput[];
2689
+ /** Omit for the server default (`proposed`); pass `draft` for staged uploads. */
2690
+ status?: 'draft' | 'proposed';
2652
2691
  source_chat_id?: string;
2653
2692
  source_message_id?: string;
2654
2693
  source_run_id?: string;
@@ -2658,6 +2697,8 @@ export interface UpdateWikiChangesetRequest {
2658
2697
  title?: string;
2659
2698
  message?: string;
2660
2699
  file_changes?: WikiFileChangeInput[];
2700
+ /** Author-settable transitions currently include `proposed` and `closed`. */
2701
+ status?: 'proposed' | 'closed';
2661
2702
  /**
2662
2703
  * When true, file_changes define the changeset's FULL content: the server
2663
2704
  * resets the feature branch to the current default-branch HEAD before
@@ -2823,17 +2864,6 @@ export interface WikiBlob {
2823
2864
  signed_url_expires_at?: string;
2824
2865
  }
2825
2866
 
2826
- export type WikiStoredAs = 'git_blob' | 'lfs_pointer';
2827
-
2828
- /** Response from POST /uploads or POST /changesets/{csId}/files. */
2829
- export interface WikiFileUploadResponse {
2830
- path: string;
2831
- size: number;
2832
- stored_as: WikiStoredAs;
2833
- commit_sha: string;
2834
- content_sha: string;
2835
- }
2836
-
2837
2867
  /** Response from POST /files/preview-url — short-lived signed URL for
2838
2868
  * browser `<img>`/`<video>`/`<iframe>` src. TTL is 5 minutes by default. */
2839
2869
  export interface WikiFilePreviewUrlResponse {
@@ -4152,6 +4182,8 @@ export interface ResolvedRef {
4152
4182
  type: string;
4153
4183
  exists: boolean;
4154
4184
  restricted?: boolean;
4185
+ /** The target could not be resolved because a dependency failed temporarily. */
4186
+ unavailable?: boolean;
4155
4187
 
4156
4188
  // User fields
4157
4189
  display_name?: string;
@@ -0,0 +1,13 @@
1
+ import type { WikiChangeset } from './types.js';
2
+
3
+ /** Normalize fields that older wiki-service responses may omit. */
4
+ export function normalizeWikiChangeset(changeset: WikiChangeset): WikiChangeset {
5
+ return {
6
+ ...changeset,
7
+ head_branch: changeset.head_branch ?? null,
8
+ base_commit: changeset.base_commit ?? null,
9
+ merge_commit: changeset.merge_commit ?? null,
10
+ changed_paths: changeset.changed_paths ?? [],
11
+ file_changes: changeset.file_changes ?? [],
12
+ };
13
+ }
@@ -0,0 +1,142 @@
1
+ interface WikiFileUploadParamsBase {
2
+ path: string;
3
+ file: Blob;
4
+ message?: string;
5
+ /** Stable per-file retry key; replays return the original successful path. */
6
+ uploadId?: string;
7
+ /** Preserve the legacy 409 by default, or atomically choose `name (N).ext`. */
8
+ conflict?: 'error' | 'rename';
9
+ signal?: AbortSignal;
10
+ onProgress?: (uploadedBytes: number, totalBytes: number) => void;
11
+ }
12
+
13
+ export type WikiStoredAs = 'git_blob' | 'lfs_pointer';
14
+
15
+ /** Response from POST /uploads or POST /changesets/{csId}/files. */
16
+ export interface WikiFileUploadResponse {
17
+ path: string;
18
+ size: number;
19
+ stored_as: WikiStoredAs;
20
+ commit_sha: string;
21
+ content_sha: string;
22
+ }
23
+
24
+ /** Parameters for the maintain-only direct upload endpoint. */
25
+ export type WikiFileUploadParams = WikiFileUploadParamsBase;
26
+
27
+ /** Parameters for a changeset-scoped multipart upload. */
28
+ export interface WikiChangesetFileUploadParams extends WikiFileUploadParamsBase {
29
+ /** `auto` accepts reviewable text as a Git blob on the changeset branch. */
30
+ contentRoute?: 'binary' | 'auto';
31
+ }
32
+
33
+ export function createWikiUploadFormData(
34
+ params: WikiFileUploadParams | WikiChangesetFileUploadParams,
35
+ ): FormData {
36
+ const form = new FormData();
37
+ form.append('path', params.path);
38
+ form.append('file', params.file);
39
+ if (params.message) form.append('message', params.message);
40
+ if (params.uploadId) form.append('upload_id', params.uploadId);
41
+ if (params.conflict) form.append('conflict', params.conflict);
42
+ if ('contentRoute' in params && params.contentRoute) {
43
+ form.append('content_route', params.contentRoute);
44
+ }
45
+ return form;
46
+ }
47
+
48
+ interface MultipartTransportOptions {
49
+ method: string;
50
+ url: string;
51
+ headers: Record<string, string>;
52
+ body: FormData;
53
+ timeoutMs: number;
54
+ signal?: AbortSignal;
55
+ onProgress?: (uploadedBytes: number, totalBytes: number) => void;
56
+ }
57
+
58
+ /** Send one multipart request. XHR is used only when a browser caller asks
59
+ * for upload progress; fetch remains the transport everywhere else. Auth
60
+ * refresh and API error decoding stay in ParallClient above this boundary. */
61
+ export function sendMultipartRequest(options: MultipartTransportOptions): Promise<Response> {
62
+ if (options.onProgress && typeof XMLHttpRequest !== 'undefined') {
63
+ return multipartXHR(options, options.onProgress);
64
+ }
65
+ const timeoutSignal = AbortSignal.timeout(options.timeoutMs);
66
+ const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal;
67
+ return fetch(options.url, {
68
+ method: options.method,
69
+ headers: options.headers,
70
+ body: options.body,
71
+ signal,
72
+ });
73
+ }
74
+
75
+ function multipartXHR(
76
+ options: MultipartTransportOptions,
77
+ onProgress: (uploadedBytes: number, totalBytes: number) => void,
78
+ ): Promise<Response> {
79
+ return new Promise((resolve, reject) => {
80
+ const xhr = new XMLHttpRequest();
81
+ let settled = false;
82
+
83
+ const finish = (fn: () => void) => {
84
+ if (settled) return;
85
+ settled = true;
86
+ options.signal?.removeEventListener('abort', abortFromSignal);
87
+ fn();
88
+ };
89
+ const abortFromSignal = () => xhr.abort();
90
+
91
+ xhr.open(options.method, options.url, true);
92
+ xhr.timeout = options.timeoutMs;
93
+ for (const [name, value] of Object.entries(options.headers)) {
94
+ xhr.setRequestHeader(name, value);
95
+ }
96
+
97
+ xhr.upload.onprogress = (event) => {
98
+ try {
99
+ onProgress(event.loaded, event.lengthComputable ? event.total : 0);
100
+ } catch {
101
+ // Observer failures must not cancel an otherwise healthy upload.
102
+ }
103
+ };
104
+ xhr.onload = () =>
105
+ finish(() => {
106
+ const responseHeaders = new Headers();
107
+ for (const line of xhr
108
+ .getAllResponseHeaders()
109
+ .trim()
110
+ .split(/[\r\n]+/)) {
111
+ if (!line) continue;
112
+ const separator = line.indexOf(':');
113
+ if (separator > 0) {
114
+ responseHeaders.append(
115
+ line.slice(0, separator).trim(),
116
+ line.slice(separator + 1).trim(),
117
+ );
118
+ }
119
+ }
120
+ const responseBody =
121
+ xhr.status === 204 || xhr.responseText === '' ? null : xhr.responseText;
122
+ resolve(
123
+ new Response(responseBody, {
124
+ status: xhr.status,
125
+ statusText: xhr.statusText,
126
+ headers: responseHeaders,
127
+ }),
128
+ );
129
+ });
130
+ xhr.onerror = () => finish(() => reject(new TypeError('Network request failed')));
131
+ xhr.ontimeout = () =>
132
+ finish(() => reject(new DOMException('Request timed out', 'TimeoutError')));
133
+ xhr.onabort = () => finish(() => reject(new DOMException('Request aborted', 'AbortError')));
134
+
135
+ if (options.signal?.aborted) {
136
+ finish(() => reject(new DOMException('Request aborted', 'AbortError')));
137
+ return;
138
+ }
139
+ options.signal?.addEventListener('abort', abortFromSignal, { once: true });
140
+ xhr.send(options.body);
141
+ });
142
+ }