diff --git a/src/webidl/domexception.ts b/src/domexception.ts similarity index 100% rename from src/webidl/domexception.ts rename to src/domexception.ts diff --git a/src/file.ts b/src/file.ts new file mode 100644 index 0000000..ec7ac58 --- /dev/null +++ b/src/file.ts @@ -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; + } +} diff --git a/src/formdata.ts b/src/formdata.ts index 8a43d25..7204084 100644 --- a/src/formdata.ts +++ b/src/formdata.ts @@ -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>(); + + /** + * @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([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([entry])); + } + + *[Symbol.iterator](): IterableIterator<[string, FormDataEntryValue]> { + for (const [name, entrySet] of this.#entryList.entries()) { + for (const entry of entrySet.values()) { + yield [name, entry]; + } + } } } diff --git a/src/index.ts b/src/index.ts index 02ecb99..ad3efb6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; diff --git a/src/xmlhttprequest.ts b/src/xmlhttprequest.ts index bc5da21..ab94d7c 100644 --- a/src/xmlhttprequest.ts +++ b/src/xmlhttprequest.ts @@ -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'; @@ -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 = @@ -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) => { @@ -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(); } diff --git a/test/integration/formdata.ts b/test/integration/formdata.ts new file mode 100644 index 0000000..6df6c30 --- /dev/null +++ b/test/integration/formdata.ts @@ -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(); + }); + }); +}); diff --git a/test/integration/xmlhttprequest.ts b/test/integration/xmlhttprequest.ts index bcce5cd..4476908 100644 --- a/test/integration/xmlhttprequest.ts +++ b/test/integration/xmlhttprequest.ts @@ -21,6 +21,7 @@ * THE SOFTWARE. */ +import { Blob } from 'buffer'; import * as http from 'http'; import getPort from 'get-port'; import { XMLHttpRequest } from '../..'; @@ -33,17 +34,42 @@ const launchMockServer = (port: number, hostname = 'localhost') => new Promise((resolve) => { const server = http.createServer((req, res) => { const url = new URL(req.url || '/', `http://${hostname}:${port}`); - const status = parseInt(url.searchParams.get('status') || '200', 10); - const type = url.searchParams.get('type') || 'text/plain'; - const body = url.searchParams.get('body') || ''; - - res.writeHead(status, { - 'Cache-Control': 'max-age=60', - 'Content-Type': type, - Date: referenceTime.toUTCString() + const status = parseInt(url.searchParams.get('status') ?? '200', 10); + const type = url.searchParams.get('type') ?? 'text/plain'; + const body = url.searchParams.get('body') ?? ''; + const delay = parseInt(url.searchParams.get('timeout') ?? '0', 0); + + let blob = new Blob([], { + type: req.headers['content-type'] + }); + + req.addListener('data', (chunk: Buffer) => { + blob = new Blob([blob, chunk], { + type: blob.type + }); + }); + + req.addListener('end', () => { + res.writeHead(status, { + 'Cache-Control': 'max-age=60', + 'Content-Type': type, + Date: referenceTime.toUTCString() + }); + + if (body) { + res.write(body); + } else if (blob.size > 0) { + res.write(blob); + } + + if (delay > 0) { + setTimeout(() => { + res.end(); + }, delay); + } else { + res.end(); + } }); - res.write(body); - res.end(); }); server.keepAliveTimeout = defaultKeepAliveTimeout; @@ -309,7 +335,7 @@ describe('XMLHttpRequest', () => { }); describe('.responseURL', () => { - it('', (done) => { + it('basic use case', (done) => { const client = new XMLHttpRequest(); client.addEventListener('loadstart', () => { @@ -327,6 +353,39 @@ describe('XMLHttpRequest', () => { }); }); + describe('.timeout', () => { + it('basic use case', (done) => { + const client = new XMLHttpRequest(); + + client.addEventListener('timeout', () => { + done(); + }); + + client.open('GET', `${baseURL}/?delay=10000`); + client.timeout = 1_000; + client.send(null); + }); + }); + + describe('.send()', () => { + it('send URLSearchParams', () => { + const client = new XMLHttpRequest(); + const searchParams = new URLSearchParams(); + + searchParams.append('subject', 'test subject'); + searchParams.append('message', 'value1'); + searchParams.append('message', 'value2'); + searchParams.append('message', 'value3'); + + client.addEventListener('load', () => { + expect(client.responseText).toEqual('test'); + }); + + client.open('POST', `${baseURL}/`); + client.send(searchParams); + }); + }); + describe('.withCredentials', () => { it('throws InvalidStateError when readyState is DONE', (done) => { const client = new XMLHttpRequest();