Skip to content

Commit

Permalink
feat(xhr): supports FormData
Browse files Browse the repository at this point in the history
  • Loading branch information
ykzts committed Feb 16, 2023
1 parent eae75ae commit 74b8d3b
Show file tree
Hide file tree
Showing 7 changed files with 374 additions and 32 deletions.
File renamed without changes.
66 changes: 66 additions & 0 deletions src/file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* @license
* Copyright (c) 2023 Yamagishi Kazutoshi
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/

import { Blob, type BlobOptions } from 'buffer';
import { type BinaryLike } from 'crypto';

/**
* @see {@link https://w3c.github.io/FileAPI/#typedefdef-blobpart File API - typedef (BufferSource or Blob or USVString) BlobPart}
*/
export type BlobPart = BinaryLike | Blob;

/**
* @see {@link https://w3c.github.io/FileAPI/#dfn-FilePropertyBag File API - interface FilePropertyBag}
*/
export interface FilePropertyBag extends BlobOptions {
lastModified?: number;
}

/**
* @see {@link https://w3c.github.io/FileAPI/#dfn-file File API - interface File}
*/
export default class File extends Blob {
#lastModifiled: number;
#name: string;

constructor(
fileBits: BlobPart[],
fileName: string,
options?: FilePropertyBag
) {
const { lastModified = Date.now(), ...blobPropertyBag } = options ?? {};

super(fileBits, blobPropertyBag);

this.#name = fileName;
this.#lastModifiled = lastModified;
}

get lastModified(): number {
return this.#lastModifiled;
}

get name(): string {
return this.#name;
}
}
95 changes: 92 additions & 3 deletions src/formdata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,103 @@
* THE SOFTWARE.
*/

import { Blob } from 'buffer';
import File from './file';

/**
* @see {@link https://xhr.spec.whatwg.org/#formdataentryvalue XMLHttpRequest Standard - typedef (File or USVString) FormDataEntryValue}
*/
export type FormDataEntryValue = File | string;

/**
* @see {@link https://xhr.spec.whatwg.org/#interface-formdata XMLHttpRequest Standard - 5. Interface FormData}
*/
export default class FormData {
#entryList = new Map<string, Set<FormDataEntryValue>>();

/**
* @see {@link https://xhr.spec.whatwg.org/#dom-formdata-append XMLHttpRequest Standard - The append(name, value) and append(name, blobValue, filename) method}
*/
append(name: string, value: string): void;
append(name: string, blobValue: Blob, filename?: string): void;
append(name: string, value: Blob | string, ...args: string[]): void {
let entry: FormDataEntryValue;

if (value instanceof Blob) {
const filename = args[0] ?? (value instanceof File ? value.name : 'blob');
entry = new File([value], filename, { type: value.type });
} else {
entry = value;
}

const entrySet = this.#entryList.get(name);

if (entrySet) {
entrySet.add(entry);
} else {
this.#entryList.set(name, new Set<FormDataEntryValue>([entry]));
}
}

/**
* @todo Implement this function.
* @see {@link https://xhr.spec.whatwg.org/#dom-formdata-delete XMLHttpRequest Standard - The delete(name) method}
*/
append(/* name, value, filename */): void {
// wip
delete(name: string): void {
this.#entryList.delete(name);
}

/**
* @see {@link https://xhr.spec.whatwg.org/#dom-formdata-get XMLHttpRequest Standard - The get(name) method}
*/
get(name: string): FormDataEntryValue | undefined {
const entrySet = this.#entryList.get(name);

if (!entrySet) {
return;
}

return [...entrySet][0];
}

/**
* @see {@link https://xhr.spec.whatwg.org/#dom-get-getall XMLHttpRequest Standard - The getAll(name) method}
*/
getAll(name: string): FormDataEntryValue[] {
const entrySet = this.#entryList.get(name);

return entrySet ? [...entrySet] : [];
}

/**
* @see {@link https://xhr.spec.whatwg.org/#dom-formdata-has XMLHttpRequest Standard - The has(name) method}
*/
has(name: string): boolean {
return this.#entryList.has(name);
}

/**
* @see {@link https://xhr.spec.whatwg.org/#dom-formdata-set XMLHttpRequest Standard - The set(name, value) and set(name, blobValue, filename) method}
*/
set(name: string, value: string): void;
set(name: string, blobValue: Blob, filename: string): void;
set(name: string, value: Blob | string, ...args: string[]): void {
let entry: FormDataEntryValue;

if (value instanceof Blob) {
const filename = args[0] ?? (value instanceof File ? value.name : 'blob');
entry = new File([value], filename, { type: value.type });
} else {
entry = value;
}

this.#entryList.set(name, new Set<FormDataEntryValue>([entry]));
}

*[Symbol.iterator](): IterableIterator<[string, FormDataEntryValue]> {
for (const [name, entrySet] of this.#entryList.entries()) {
for (const entry of entrySet.values()) {
yield [name, entry];
}
}
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,5 @@

export { default as XMLHttpRequest } from './xmlhttprequest';
export { default as XMLHttpRequestUpload } from './xmlhttprequestupload';
export { default as File } from './file';
export { default as FormData } from './formdata';
93 changes: 75 additions & 18 deletions src/xmlhttprequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@
* THE SOFTWARE.
*/

import { Blob } from 'buffer';
import * as http from 'http';
import * as https from 'https';
import DOMException from './domexception';
import File from './file';
import FormData from './formdata';
import ProgressEvent from './progressevent';
import DOMException from './webidl/domexception';
import XMLHttpRequestEventTarget from './xmlhttprequesteventtarget';
import XMLHttpRequestUpload from './xmlhttprequestupload';

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

export type BodyInit =
| ArrayBuffer
| Buffer
export type XMLHttpRequestBodyInit =
| Blob
| BufferSource
| FormData
| URLSearchParams
| Uint8Array
| string;

export type XMLHttpRequestResponseType =
Expand Down Expand Up @@ -478,24 +479,64 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget {
/**
* @see {@link https://xhr.spec.whatwg.org/#the-send()-method XMLHttpRequest Standard - 4.5.6. The send() method}
*/
send(body: BodyInit | null = null): void {
send(body: XMLHttpRequestBodyInit | null = null): void {
if (this.readyState !== XMLHttpRequest.OPENED || !this.#client) {
// TODO: Add human readable message.
throw new DOMException('', 'InvalidStateError');
}

if (body) {
const bodyInit =
body instanceof ArrayBuffer || body instanceof Uint8Array
? Buffer.from(body)
: body;
this.dispatchEvent(new ProgressEvent('loadstart'));

if (body && !['GET', 'HEAD'].includes(this.#client.method)) {
let chunk = new Blob([]);

if (typeof bodyInit === 'string' || bodyInit instanceof Buffer) {
const length = Buffer.isBuffer(bodyInit)
? bodyInit.length
: Buffer.byteLength(bodyInit);
if (
body instanceof ArrayBuffer ||
ArrayBuffer.isView(body) ||
body instanceof Blob
) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
chunk = new Blob([body]);
} else if (body instanceof URLSearchParams) {
chunk = new Blob([body.toString()], {
type: 'application/x-www-form-urlencoded; charset=UTF-8'
});
} else if (body instanceof FormData) {
const boundary = '------xxxxx';

let chunk = new Blob([], {
type: `multipart/form-data; boundary=${boundary}`
});
for (const [name, value] of body) {
if (value instanceof File) {
chunk = new Blob(
[
chunk,
`Content-Disposition: form-data; name="${name}"; filename="${value.name}"\r\n`,
'\r\n',
value,
`\r\n`
],
{ type: chunk.type }
);
} else {
chunk = new Blob(
[
chunk,
`${boundary}\r\n`,
`Content-Disposition: form-data; name="${name}"\r\n`,
'\r\n',
`${value}\r\n`
],
{ type: chunk.type }
);
}
}

this.#client.setHeader('Content-Length', length);
chunk = new Blob([chunk, `${boundary}\r\n`], { type: chunk.type });
} else {
chunk = new Blob([body], { type: 'text/plain' });
}

this.#client.addListener('socket', (socket) => {
Expand All @@ -511,10 +552,26 @@ export default class XMLHttpRequest extends XMLHttpRequestEventTarget {
});
});

this.#client.write(body);
if (chunk.type) {
this.setRequestHeader('Content-Type', chunk.type);
}

this.setRequestHeader('Content-Length', chunk.size.toString());

chunk
.arrayBuffer()
.then((buffer) => {
if (!this.#client) {
throw new TypeError('The client is initialized unintentionally.');
}

this.#client.write(new Uint8Array(buffer));
})
.catch((error) => {
throw error;
});
}

this.dispatchEvent(new ProgressEvent('loadstart'));
this.#client.end();
}

Expand Down
70 changes: 70 additions & 0 deletions test/integration/formdata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* @license
* Copyright (c) 2023 Yamagishi Kazutoshi
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/

import { FormData } from '../..';

describe('FormData', () => {
describe('.append()', () => {
it('basic use case', () => {
const formData = new FormData();

expect(formData.append('message', 'test message')).toBeUndefined();
});
});

describe('.delete()', () => {
it('basic use case', () => {
const formData = new FormData();
});
});

describe('.get()', () => {
it('basic use case', () => {
const formData = new FormData();
});
});

describe('.getAll()', () => {
it('basic use case', () => {
const formData = new FormData();
});
});

describe('.has()', () => {
it('basic use case', () => {
const formData = new FormData();
});
});

describe('.set()', () => {
it('basic use case', () => {
const formData = new FormData();
});
});

describe('.[Symbol.iterator]()', () => {
it('basic use case', () => {
const formData = new FormData();
});
});
});
Loading

0 comments on commit 74b8d3b

Please sign in to comment.