@doist/comms-sdk 2.0.1 → 2.0.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.
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.getContentTypeFromFileName = getContentTypeFromFileName;
|
|
4
4
|
exports.uploadMultipartFile = uploadMultipartFile;
|
|
5
|
+
const uuid_1 = require("uuid");
|
|
5
6
|
const fetch_with_retry_1 = require("../transport/fetch-with-retry");
|
|
6
7
|
/**
|
|
7
8
|
* Determine a content-type from a filename extension. Falls back to
|
|
@@ -71,8 +72,7 @@ const MIME_BY_EXTENSION = {
|
|
|
71
72
|
};
|
|
72
73
|
/**
|
|
73
74
|
* Normalise a supported {@link UploadFile} into a `Blob` plus a resolved file name and
|
|
74
|
-
* content type,
|
|
75
|
-
* `undici` and browsers both accept natively.
|
|
75
|
+
* content type, ready to be encoded into the multipart body.
|
|
76
76
|
*/
|
|
77
77
|
function toBlob(file, fileName, contentType) {
|
|
78
78
|
if (file instanceof Blob) {
|
|
@@ -97,15 +97,72 @@ function toBlob(file, fileName, contentType) {
|
|
|
97
97
|
}
|
|
98
98
|
throw new Error('Unsupported file type for upload: expected a Blob or Uint8Array');
|
|
99
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Escapes a value for use inside a quoted `Content-Disposition` parameter, where a raw
|
|
102
|
+
* quote would end the value early and a raw CR or LF would end the header. A lone CR
|
|
103
|
+
* needs escaping too, not just CRLF pairs.
|
|
104
|
+
*
|
|
105
|
+
* This is the same escaping the platform's own `FormData` encoder applies, so servers
|
|
106
|
+
* see exactly what they would from any browser.
|
|
107
|
+
*/
|
|
108
|
+
function escapeDispositionValue(value) {
|
|
109
|
+
return value.replace(/"/g, '%22').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Rejects a media type that could not be safely written into a part header.
|
|
113
|
+
*
|
|
114
|
+
* `contentType` is public caller input and reaches the header verbatim. A value
|
|
115
|
+
* carrying a CR or LF would end the header early and let the caller forge one
|
|
116
|
+
* of its own — `Blob` normalises the `type` it stores, but the supplied value
|
|
117
|
+
* is what gets interpolated. Reject rather than silently rewrite, so a caller
|
|
118
|
+
* passing something unusable finds out instead of having it altered.
|
|
119
|
+
*/
|
|
120
|
+
function assertHeaderSafeContentType(contentType) {
|
|
121
|
+
if (!/^[\x20-\x7e]*$/.test(contentType)) {
|
|
122
|
+
throw new Error('contentType must contain only printable ASCII characters, without CR or LF');
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Encodes a `multipart/form-data` body as a `Blob`, together with the `Content-Type`
|
|
127
|
+
* that describes it.
|
|
128
|
+
*
|
|
129
|
+
* Deliberately avoids `FormData`. A `FormData` body is only encoded by the `fetch` that
|
|
130
|
+
* owns that `FormData` class — undici brands its own and checks it with a plain
|
|
131
|
+
* `instanceof` — and the SDK dispatches through undici's own `fetch` to keep the request
|
|
132
|
+
* client on the same undici as the dispatcher. A global `FormData` handed to it is
|
|
133
|
+
* stringified to the literal `"[object FormData]"`, so the upload silently carries no
|
|
134
|
+
* file. `Blob` has no such brand and works with whichever `fetch` sends the request,
|
|
135
|
+
* including a caller-supplied `customFetch`.
|
|
136
|
+
*/
|
|
137
|
+
function buildMultipartBody(args) {
|
|
138
|
+
const { blob, fileName, contentType, fields } = args;
|
|
139
|
+
assertHeaderSafeContentType(contentType);
|
|
140
|
+
const boundary = `----comms-sdk-${(0, uuid_1.v4)()}`;
|
|
141
|
+
const parts = [
|
|
142
|
+
`--${boundary}\r\n` +
|
|
143
|
+
`Content-Disposition: form-data; name="file"; filename="${escapeDispositionValue(fileName)}"\r\n` +
|
|
144
|
+
`Content-Type: ${contentType}\r\n\r\n`,
|
|
145
|
+
blob,
|
|
146
|
+
'\r\n',
|
|
147
|
+
];
|
|
148
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
149
|
+
if (value !== undefined && value !== null) {
|
|
150
|
+
parts.push(`--${boundary}\r\n` +
|
|
151
|
+
`Content-Disposition: form-data; name="${escapeDispositionValue(key)}"\r\n\r\n` +
|
|
152
|
+
`${String(value)}\r\n`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
parts.push(`--${boundary}--\r\n`);
|
|
156
|
+
return { body: new Blob(parts), contentType: `multipart/form-data; boundary=${boundary}` };
|
|
157
|
+
}
|
|
100
158
|
/**
|
|
101
159
|
* Upload a file using `multipart/form-data`.
|
|
102
160
|
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
161
|
+
* Encodes the request body as a `Blob` so it works unchanged in the browser and in
|
|
162
|
+
* Node.js, whichever `fetch` ends up sending it. The `file` part is sent alongside `file_name`,
|
|
105
163
|
* `file_size`, and `underlying_type` fields (the canonical Comms upload shape); any
|
|
106
164
|
* `additionalFields` are merged in and override the derived values. Authentication uses
|
|
107
|
-
* `Authorization: Bearer`, matching every other Comms SDK client
|
|
108
|
-
* intentionally left unset so the runtime adds the correct multipart boundary.
|
|
165
|
+
* `Authorization: Bearer`, matching every other Comms SDK client.
|
|
109
166
|
*
|
|
110
167
|
* The response is JSON-parsed and camel-cased by {@link fetchWithRetry}; callers validate
|
|
111
168
|
* the returned shape with the appropriate schema.
|
|
@@ -119,15 +176,17 @@ async function uploadMultipartFile(args) {
|
|
|
119
176
|
underlying_type: resolvedType,
|
|
120
177
|
...additionalFields,
|
|
121
178
|
};
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
179
|
+
const multipart = buildMultipartBody({
|
|
180
|
+
blob,
|
|
181
|
+
fileName: resolvedFileName,
|
|
182
|
+
contentType: resolvedType,
|
|
183
|
+
fields,
|
|
184
|
+
});
|
|
129
185
|
const headers = {
|
|
130
186
|
Authorization: `Bearer ${authToken}`,
|
|
187
|
+
// The boundary is ours, so this has to be declared explicitly — `fetch`
|
|
188
|
+
// only fills it in for a `FormData` body.
|
|
189
|
+
'Content-Type': multipart.contentType,
|
|
131
190
|
};
|
|
132
191
|
if (requestId) {
|
|
133
192
|
headers['X-Request-Id'] = requestId;
|
|
@@ -136,8 +195,7 @@ async function uploadMultipartFile(args) {
|
|
|
136
195
|
const response = await (0, fetch_with_retry_1.fetchWithRetry)(url, {
|
|
137
196
|
method: 'POST',
|
|
138
197
|
headers,
|
|
139
|
-
body:
|
|
140
|
-
// Don't set Content-Type — the runtime adds the multipart boundary.
|
|
198
|
+
body: multipart.body,
|
|
141
199
|
timeout: 30000,
|
|
142
200
|
}, maxRetries, customFetch);
|
|
143
201
|
return response.data;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { v4 as uuid } from 'uuid';
|
|
1
2
|
import { fetchWithRetry } from '../transport/fetch-with-retry.js';
|
|
2
3
|
/**
|
|
3
4
|
* Determine a content-type from a filename extension. Falls back to
|
|
@@ -67,8 +68,7 @@ const MIME_BY_EXTENSION = {
|
|
|
67
68
|
};
|
|
68
69
|
/**
|
|
69
70
|
* Normalise a supported {@link UploadFile} into a `Blob` plus a resolved file name and
|
|
70
|
-
* content type,
|
|
71
|
-
* `undici` and browsers both accept natively.
|
|
71
|
+
* content type, ready to be encoded into the multipart body.
|
|
72
72
|
*/
|
|
73
73
|
function toBlob(file, fileName, contentType) {
|
|
74
74
|
if (file instanceof Blob) {
|
|
@@ -93,15 +93,72 @@ function toBlob(file, fileName, contentType) {
|
|
|
93
93
|
}
|
|
94
94
|
throw new Error('Unsupported file type for upload: expected a Blob or Uint8Array');
|
|
95
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* Escapes a value for use inside a quoted `Content-Disposition` parameter, where a raw
|
|
98
|
+
* quote would end the value early and a raw CR or LF would end the header. A lone CR
|
|
99
|
+
* needs escaping too, not just CRLF pairs.
|
|
100
|
+
*
|
|
101
|
+
* This is the same escaping the platform's own `FormData` encoder applies, so servers
|
|
102
|
+
* see exactly what they would from any browser.
|
|
103
|
+
*/
|
|
104
|
+
function escapeDispositionValue(value) {
|
|
105
|
+
return value.replace(/"/g, '%22').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Rejects a media type that could not be safely written into a part header.
|
|
109
|
+
*
|
|
110
|
+
* `contentType` is public caller input and reaches the header verbatim. A value
|
|
111
|
+
* carrying a CR or LF would end the header early and let the caller forge one
|
|
112
|
+
* of its own — `Blob` normalises the `type` it stores, but the supplied value
|
|
113
|
+
* is what gets interpolated. Reject rather than silently rewrite, so a caller
|
|
114
|
+
* passing something unusable finds out instead of having it altered.
|
|
115
|
+
*/
|
|
116
|
+
function assertHeaderSafeContentType(contentType) {
|
|
117
|
+
if (!/^[\x20-\x7e]*$/.test(contentType)) {
|
|
118
|
+
throw new Error('contentType must contain only printable ASCII characters, without CR or LF');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Encodes a `multipart/form-data` body as a `Blob`, together with the `Content-Type`
|
|
123
|
+
* that describes it.
|
|
124
|
+
*
|
|
125
|
+
* Deliberately avoids `FormData`. A `FormData` body is only encoded by the `fetch` that
|
|
126
|
+
* owns that `FormData` class — undici brands its own and checks it with a plain
|
|
127
|
+
* `instanceof` — and the SDK dispatches through undici's own `fetch` to keep the request
|
|
128
|
+
* client on the same undici as the dispatcher. A global `FormData` handed to it is
|
|
129
|
+
* stringified to the literal `"[object FormData]"`, so the upload silently carries no
|
|
130
|
+
* file. `Blob` has no such brand and works with whichever `fetch` sends the request,
|
|
131
|
+
* including a caller-supplied `customFetch`.
|
|
132
|
+
*/
|
|
133
|
+
function buildMultipartBody(args) {
|
|
134
|
+
const { blob, fileName, contentType, fields } = args;
|
|
135
|
+
assertHeaderSafeContentType(contentType);
|
|
136
|
+
const boundary = `----comms-sdk-${uuid()}`;
|
|
137
|
+
const parts = [
|
|
138
|
+
`--${boundary}\r\n` +
|
|
139
|
+
`Content-Disposition: form-data; name="file"; filename="${escapeDispositionValue(fileName)}"\r\n` +
|
|
140
|
+
`Content-Type: ${contentType}\r\n\r\n`,
|
|
141
|
+
blob,
|
|
142
|
+
'\r\n',
|
|
143
|
+
];
|
|
144
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
145
|
+
if (value !== undefined && value !== null) {
|
|
146
|
+
parts.push(`--${boundary}\r\n` +
|
|
147
|
+
`Content-Disposition: form-data; name="${escapeDispositionValue(key)}"\r\n\r\n` +
|
|
148
|
+
`${String(value)}\r\n`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
parts.push(`--${boundary}--\r\n`);
|
|
152
|
+
return { body: new Blob(parts), contentType: `multipart/form-data; boundary=${boundary}` };
|
|
153
|
+
}
|
|
96
154
|
/**
|
|
97
155
|
* Upload a file using `multipart/form-data`.
|
|
98
156
|
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
157
|
+
* Encodes the request body as a `Blob` so it works unchanged in the browser and in
|
|
158
|
+
* Node.js, whichever `fetch` ends up sending it. The `file` part is sent alongside `file_name`,
|
|
101
159
|
* `file_size`, and `underlying_type` fields (the canonical Comms upload shape); any
|
|
102
160
|
* `additionalFields` are merged in and override the derived values. Authentication uses
|
|
103
|
-
* `Authorization: Bearer`, matching every other Comms SDK client
|
|
104
|
-
* intentionally left unset so the runtime adds the correct multipart boundary.
|
|
161
|
+
* `Authorization: Bearer`, matching every other Comms SDK client.
|
|
105
162
|
*
|
|
106
163
|
* The response is JSON-parsed and camel-cased by {@link fetchWithRetry}; callers validate
|
|
107
164
|
* the returned shape with the appropriate schema.
|
|
@@ -115,15 +172,17 @@ export async function uploadMultipartFile(args) {
|
|
|
115
172
|
underlying_type: resolvedType,
|
|
116
173
|
...additionalFields,
|
|
117
174
|
};
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
175
|
+
const multipart = buildMultipartBody({
|
|
176
|
+
blob,
|
|
177
|
+
fileName: resolvedFileName,
|
|
178
|
+
contentType: resolvedType,
|
|
179
|
+
fields,
|
|
180
|
+
});
|
|
125
181
|
const headers = {
|
|
126
182
|
Authorization: `Bearer ${authToken}`,
|
|
183
|
+
// The boundary is ours, so this has to be declared explicitly — `fetch`
|
|
184
|
+
// only fills it in for a `FormData` body.
|
|
185
|
+
'Content-Type': multipart.contentType,
|
|
127
186
|
};
|
|
128
187
|
if (requestId) {
|
|
129
188
|
headers['X-Request-Id'] = requestId;
|
|
@@ -132,8 +191,7 @@ export async function uploadMultipartFile(args) {
|
|
|
132
191
|
const response = await fetchWithRetry(url, {
|
|
133
192
|
method: 'POST',
|
|
134
193
|
headers,
|
|
135
|
-
body:
|
|
136
|
-
// Don't set Content-Type — the runtime adds the multipart boundary.
|
|
194
|
+
body: multipart.body,
|
|
137
195
|
timeout: 30000,
|
|
138
196
|
}, maxRetries, customFetch);
|
|
139
197
|
return response.data;
|
|
@@ -44,12 +44,11 @@ export declare function getContentTypeFromFileName(fileName: string): string;
|
|
|
44
44
|
/**
|
|
45
45
|
* Upload a file using `multipart/form-data`.
|
|
46
46
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
47
|
+
* Encodes the request body as a `Blob` so it works unchanged in the browser and in
|
|
48
|
+
* Node.js, whichever `fetch` ends up sending it. The `file` part is sent alongside `file_name`,
|
|
49
49
|
* `file_size`, and `underlying_type` fields (the canonical Comms upload shape); any
|
|
50
50
|
* `additionalFields` are merged in and override the derived values. Authentication uses
|
|
51
|
-
* `Authorization: Bearer`, matching every other Comms SDK client
|
|
52
|
-
* intentionally left unset so the runtime adds the correct multipart boundary.
|
|
51
|
+
* `Authorization: Bearer`, matching every other Comms SDK client.
|
|
53
52
|
*
|
|
54
53
|
* The response is JSON-parsed and camel-cased by {@link fetchWithRetry}; callers validate
|
|
55
54
|
* the returned shape with the appropriate schema.
|