Skip to content

Commit 1b3d919

Browse files
committed
feat(xhr): supports FormData
1 parent eae75ae commit 1b3d919

File tree

7 files changed

+255
-20
lines changed

7 files changed

+255
-20
lines changed
File renamed without changes.

src/file.ts

+66
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* @license
3+
* Copyright (c) 2023 Yamagishi Kazutoshi
4+
*
5+
* Permission is hereby granted, free of charge, to any person obtaining a copy
6+
* of this software and associated documentation files (the "Software"), to deal
7+
* in the Software without restriction, including without limitation the rights
8+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
* copies of the Software, and to permit persons to whom the Software is
10+
* furnished to do so, subject to the following conditions:
11+
*
12+
* The above copyright notice and this permission notice shall be included in
13+
* all copies or substantial portions of the Software.
14+
*
15+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21+
* THE SOFTWARE.
22+
*/
23+
24+
import { Blob, type BlobOptions } from 'buffer';
25+
import { type BinaryLike } from 'crypto';
26+
27+
/**
28+
* @see {@link https://w3c.github.io/FileAPI/#typedefdef-blobpart File API - typedef (BufferSource or Blob or USVString) BlobPart}
29+
*/
30+
export type BlobPart = BinaryLike | Blob;
31+
32+
/**
33+
* @see {@link https://w3c.github.io/FileAPI/#dfn-FilePropertyBag File API - interface FilePropertyBag}
34+
*/
35+
export interface FilePropertyBag extends BlobOptions {
36+
lastModified?: number;
37+
}
38+
39+
/**
40+
* @see {@link https://w3c.github.io/FileAPI/#dfn-file File API - interface File}
41+
*/
42+
export default class File extends Blob {
43+
#lastModifiled: number;
44+
#name: string;
45+
46+
constructor(
47+
fileBits: BlobPart[],
48+
fileName: string,
49+
options?: FilePropertyBag
50+
) {
51+
const { lastModified = Date.now(), ...blobPropertyBag } = options ?? {};
52+
53+
super(fileBits, blobPropertyBag);
54+
55+
this.#name = fileName;
56+
this.#lastModifiled = lastModified;
57+
}
58+
59+
get lastModified(): number {
60+
return this.#lastModifiled;
61+
}
62+
63+
get name(): string {
64+
return this.#name;
65+
}
66+
}

src/formdata.ts

+92-3
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,103 @@
2121
* THE SOFTWARE.
2222
*/
2323

24+
import { Blob } from 'buffer';
25+
import File from './file';
26+
27+
/**
28+
* @see {@link https://xhr.spec.whatwg.org/#formdataentryvalue XMLHttpRequest Standard - typedef (File or USVString) FormDataEntryValue}
29+
*/
30+
export type FormDataEntryValue = File | string;
31+
2432
/**
2533
* @see {@link https://xhr.spec.whatwg.org/#interface-formdata XMLHttpRequest Standard - 5. Interface FormData}
2634
*/
2735
export default class FormData {
36+
#entryList = new Map<string, Set<FormDataEntryValue>>();
37+
38+
/**
39+
* @see {@link https://xhr.spec.whatwg.org/#dom-formdata-append XMLHttpRequest Standard - The append(name, value) and append(name, blobValue, filename) method}
40+
*/
41+
append(name: string, value: string): void;
42+
append(name: string, blobValue: Blob, filename?: string): void;
43+
append(name: string, value: Blob | string, ...args: string[]): void {
44+
let entry: FormDataEntryValue;
45+
46+
if (value instanceof Blob) {
47+
const filename = args[0] ?? (value instanceof File ? value.name : 'blob');
48+
entry = new File([value], filename, { type: value.type });
49+
} else {
50+
entry = value;
51+
}
52+
53+
const entrySet = this.#entryList.get(name);
54+
55+
if (entrySet) {
56+
entrySet.add(entry);
57+
} else {
58+
this.#entryList.set(name, new Set<FormDataEntryValue>([entry]));
59+
}
60+
}
61+
2862
/**
29-
* @todo Implement this function.
63+
* @see {@link https://xhr.spec.whatwg.org/#dom-formdata-delete XMLHttpRequest Standard - The delete(name) method}
3064
*/
31-
append(/* name, value, filename */): void {
32-
// wip
65+
delete(name: string): void {
66+
this.#entryList.delete(name);
67+
}
68+
69+
/**
70+
* @see {@link https://xhr.spec.whatwg.org/#dom-formdata-get XMLHttpRequest Standard - The get(name) method}
71+
*/
72+
get(name: string): FormDataEntryValue | undefined {
73+
const entrySet = this.#entryList.get(name);
74+
75+
if (!entrySet) {
76+
return;
77+
}
78+
79+
return [...entrySet][0];
80+
}
81+
82+
/**
83+
* @see {@link https://xhr.spec.whatwg.org/#dom-get-getall XMLHttpRequest Standard - The getAll(name) method}
84+
*/
85+
getAll(name: string): FormDataEntryValue[] {
86+
const entrySet = this.#entryList.get(name);
87+
88+
return entrySet ? [...entrySet] : [];
89+
}
90+
91+
/**
92+
* @see {@link https://xhr.spec.whatwg.org/#dom-formdata-has XMLHttpRequest Standard - The has(name) method}
93+
*/
94+
has(name: string): boolean {
95+
return this.#entryList.has(name);
96+
}
97+
98+
/**
99+
* @see {@link https://xhr.spec.whatwg.org/#dom-formdata-set XMLHttpRequest Standard - The set(name, value) and set(name, blobValue, filename) method}
100+
*/
101+
set(name: string, value: string): void;
102+
set(name: string, blobValue: Blob, filename: string): void;
103+
set(name: string, value: Blob | string, ...args: string[]): void {
104+
let entry: FormDataEntryValue;
105+
106+
if (value instanceof Blob) {
107+
const filename = args[0] ?? (value instanceof File ? value.name : 'blob');
108+
entry = new File([value], filename, { type: value.type });
109+
} else {
110+
entry = value;
111+
}
112+
113+
this.#entryList.set(name, new Set<FormDataEntryValue>([entry]));
114+
}
115+
116+
*[Symbol.iterator](): IterableIterator<[string, FormDataEntryValue]> {
117+
for (const [name, entrySet] of this.#entryList.entries()) {
118+
for (const entry of entrySet.values()) {
119+
yield [name, entry];
120+
}
121+
}
33122
}
34123
}

src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,5 @@
2323

2424
export { default as XMLHttpRequest } from './xmlhttprequest';
2525
export { default as XMLHttpRequestUpload } from './xmlhttprequestupload';
26+
export { default as File } from './file';
2627
export { default as FormData } from './formdata';

src/xmlhttprequest.ts

+61-16
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,13 @@
2121
* THE SOFTWARE.
2222
*/
2323

24+
import { Blob } from 'buffer';
2425
import * as http from 'http';
2526
import * as https from 'https';
27+
import DOMException from './domexception';
28+
import File from './file';
2629
import FormData from './formdata';
2730
import ProgressEvent from './progressevent';
28-
import DOMException from './webidl/domexception';
2931
import XMLHttpRequestEventTarget from './xmlhttprequesteventtarget';
3032
import XMLHttpRequestUpload from './xmlhttprequestupload';
3133

@@ -77,12 +79,11 @@ const FORBIDDEN_RESPONSE_HEADERS = ['set-cookie', 'set-cookie2'];
7779
*/
7880
const HTTP_HEADER_FIELD_NAME_REGEXP = /[!#$%&'*+-.^_`|~a-z0-9]+/;
7981

80-
export type BodyInit =
81-
| ArrayBuffer
82-
| Buffer
82+
export type XMLHttpRequestBodyInit =
83+
| Blob
84+
| BufferSource
8385
| FormData
8486
| URLSearchParams
85-
| Uint8Array
8687
| string;
8788

8889
export type XMLHttpRequestResponseType =
@@ -478,24 +479,62 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget {
478479
/**
479480
* @see {@link https://xhr.spec.whatwg.org/#the-send()-method XMLHttpRequest Standard - 4.5.6. The send() method}
480481
*/
481-
send(body: BodyInit | null = null): void {
482+
send(body: XMLHttpRequestBodyInit | null = null): void {
482483
if (this.readyState !== XMLHttpRequest.OPENED || !this.#client) {
483484
// TODO: Add human readable message.
484485
throw new DOMException('', 'InvalidStateError');
485486
}
486487

487-
if (body) {
488-
const bodyInit =
489-
body instanceof ArrayBuffer || body instanceof Uint8Array
490-
? Buffer.from(body)
491-
: body;
488+
if (body && !['GET', 'HEAD'].includes(this.#client.method)) {
489+
let chunk = new Blob([]);
492490

493-
if (typeof bodyInit === 'string' || bodyInit instanceof Buffer) {
494-
const length = Buffer.isBuffer(bodyInit)
495-
? bodyInit.length
496-
: Buffer.byteLength(bodyInit);
491+
if (
492+
body instanceof ArrayBuffer ||
493+
ArrayBuffer.isView(body) ||
494+
body instanceof Blob
495+
) {
496+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
497+
// @ts-ignore
498+
chunk = new Blob([body]);
499+
} else if (body instanceof URLSearchParams) {
500+
chunk = new Blob([body.toString()], {
501+
type: 'application/x-www-form-urlencoded; charset=UTF-8'
502+
});
503+
} else if (body instanceof FormData) {
504+
const boundary = '------xxxxx';
497505

498-
this.#client.setHeader('Content-Length', length);
506+
let chunk = new Blob([], {
507+
type: `multipart/form-data; boundary=${boundary}`
508+
});
509+
for (const [name, value] of body) {
510+
if (value instanceof File) {
511+
chunk = new Blob(
512+
[
513+
chunk,
514+
`Content-Disposition: form-data; name="${name}"; filename="${value.name}"\r\n`,
515+
'\r\n',
516+
value,
517+
`\r\n`
518+
],
519+
{ type: chunk.type }
520+
);
521+
} else {
522+
chunk = new Blob(
523+
[
524+
chunk,
525+
`${boundary}\r\n`,
526+
`Content-Disposition: form-data; name="${name}"\r\n`,
527+
'\r\n',
528+
`${value}\r\n`
529+
],
530+
{ type: chunk.type }
531+
);
532+
}
533+
}
534+
535+
chunk = new Blob([chunk, `${boundary}\r\n`], { type: chunk.type });
536+
} else {
537+
chunk = new Blob([body], { type: 'text/plain' });
499538
}
500539

501540
this.#client.addListener('socket', (socket) => {
@@ -511,6 +550,12 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget {
511550
});
512551
});
513552

553+
if (chunk.type) {
554+
this.setRequestHeader('Content-Type', chunk.type);
555+
}
556+
557+
this.setRequestHeader('Content-Length', chunk.size.toString());
558+
514559
this.#client.write(body);
515560
}
516561

test/integration/formdata.ts

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* @license
3+
* Copyright (c) 2023 Yamagishi Kazutoshi
4+
*
5+
* Permission is hereby granted, free of charge, to any person obtaining a copy
6+
* of this software and associated documentation files (the "Software"), to deal
7+
* in the Software without restriction, including without limitation the rights
8+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
* copies of the Software, and to permit persons to whom the Software is
10+
* furnished to do so, subject to the following conditions:
11+
*
12+
* The above copyright notice and this permission notice shall be included in
13+
* all copies or substantial portions of the Software.
14+
*
15+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21+
* THE SOFTWARE.
22+
*/
23+
24+
import { FormData } from '../..';
25+
26+
describe('FormData', () => {
27+
describe('.append', () => {
28+
it('basic use case', () => {
29+
const formData = new FormData();
30+
31+
expect(formData.append('message', 'test message')).toBeUndefined();
32+
});
33+
});
34+
});

test/integration/xmlhttprequest.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ describe('XMLHttpRequest', () => {
309309
});
310310

311311
describe('.responseURL', () => {
312-
it('', (done) => {
312+
it('basic use case', (done) => {
313313
const client = new XMLHttpRequest();
314314

315315
client.addEventListener('loadstart', () => {

0 commit comments

Comments
 (0)