@parall/sdk 1.56.1 → 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.
- package/dist/client.d.ts +13 -16
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +35 -29
- package/dist/constants.d.ts +2 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/types.d.ts +27 -9
- package/dist/types.d.ts.map +1 -1
- package/dist/wiki-changeset.d.ts +4 -0
- package/dist/wiki-changeset.d.ts.map +1 -0
- package/dist/wiki-changeset.js +11 -0
- package/dist/wiki-upload.d.ts +43 -0
- package/dist/wiki-upload.d.ts.map +1 -0
- package/dist/wiki-upload.js +87 -0
- package/package.json +1 -1
- package/src/client.ts +54 -29
- package/src/constants.ts +2 -0
- package/src/index.ts +6 -0
- package/src/types.ts +36 -13
- package/src/wiki-changeset.ts +13 -0
- package/src/wiki-upload.ts +142 -0
|
@@ -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
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
|
|
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
|
|
588
|
+
res = await sendMultipartRequest({
|
|
574
589
|
method,
|
|
590
|
+
url: `${this.baseUrlFor(path)}${path}`,
|
|
575
591
|
headers,
|
|
576
592
|
body,
|
|
577
|
-
|
|
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:
|
|
2960
|
+
params: WikiFileUploadParams,
|
|
2927
2961
|
): Promise<WikiFileUploadResponse> {
|
|
2928
2962
|
// `POST /uploads` always writes to the wiki's default branch — the
|
|
2929
|
-
// backend's parseUpload
|
|
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 =
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
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
|
|
2941
|
-
* author-only).
|
|
2942
|
-
*
|
|
2943
|
-
*
|
|
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:
|
|
2983
|
+
params: WikiChangesetFileUploadParams,
|
|
2950
2984
|
): Promise<WikiFileUploadResponse> {
|
|
2951
|
-
const fd =
|
|
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
|
@@ -710,11 +710,35 @@ export interface CreateInvitationRequest {
|
|
|
710
710
|
role?: OrgMemberRole;
|
|
711
711
|
}
|
|
712
712
|
|
|
713
|
-
// Response of
|
|
714
|
-
// returned exactly once at mint time and can never
|
|
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.
|
|
715
716
|
export interface ApiKey {
|
|
716
717
|
id: string;
|
|
717
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;
|
|
718
742
|
}
|
|
719
743
|
|
|
720
744
|
export interface CreateAgentResponse {
|
|
@@ -2391,6 +2415,10 @@ export interface WikiChangeset {
|
|
|
2391
2415
|
title: string;
|
|
2392
2416
|
message: string | null;
|
|
2393
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;
|
|
2394
2422
|
merge_commit: string | null;
|
|
2395
2423
|
file_changes: WikiFileChange[];
|
|
2396
2424
|
changed_paths: string[];
|
|
@@ -2658,6 +2686,8 @@ export interface CreateWikiChangesetRequest {
|
|
|
2658
2686
|
title: string;
|
|
2659
2687
|
message?: string;
|
|
2660
2688
|
file_changes: WikiFileChangeInput[];
|
|
2689
|
+
/** Omit for the server default (`proposed`); pass `draft` for staged uploads. */
|
|
2690
|
+
status?: 'draft' | 'proposed';
|
|
2661
2691
|
source_chat_id?: string;
|
|
2662
2692
|
source_message_id?: string;
|
|
2663
2693
|
source_run_id?: string;
|
|
@@ -2667,6 +2697,8 @@ export interface UpdateWikiChangesetRequest {
|
|
|
2667
2697
|
title?: string;
|
|
2668
2698
|
message?: string;
|
|
2669
2699
|
file_changes?: WikiFileChangeInput[];
|
|
2700
|
+
/** Author-settable transitions currently include `proposed` and `closed`. */
|
|
2701
|
+
status?: 'proposed' | 'closed';
|
|
2670
2702
|
/**
|
|
2671
2703
|
* When true, file_changes define the changeset's FULL content: the server
|
|
2672
2704
|
* resets the feature branch to the current default-branch HEAD before
|
|
@@ -2832,17 +2864,6 @@ export interface WikiBlob {
|
|
|
2832
2864
|
signed_url_expires_at?: string;
|
|
2833
2865
|
}
|
|
2834
2866
|
|
|
2835
|
-
export type WikiStoredAs = 'git_blob' | 'lfs_pointer';
|
|
2836
|
-
|
|
2837
|
-
/** Response from POST /uploads or POST /changesets/{csId}/files. */
|
|
2838
|
-
export interface WikiFileUploadResponse {
|
|
2839
|
-
path: string;
|
|
2840
|
-
size: number;
|
|
2841
|
-
stored_as: WikiStoredAs;
|
|
2842
|
-
commit_sha: string;
|
|
2843
|
-
content_sha: string;
|
|
2844
|
-
}
|
|
2845
|
-
|
|
2846
2867
|
/** Response from POST /files/preview-url — short-lived signed URL for
|
|
2847
2868
|
* browser `<img>`/`<video>`/`<iframe>` src. TTL is 5 minutes by default. */
|
|
2848
2869
|
export interface WikiFilePreviewUrlResponse {
|
|
@@ -4161,6 +4182,8 @@ export interface ResolvedRef {
|
|
|
4161
4182
|
type: string;
|
|
4162
4183
|
exists: boolean;
|
|
4163
4184
|
restricted?: boolean;
|
|
4185
|
+
/** The target could not be resolved because a dependency failed temporarily. */
|
|
4186
|
+
unavailable?: boolean;
|
|
4164
4187
|
|
|
4165
4188
|
// User fields
|
|
4166
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
|
+
}
|