diff --git a/op_crates/file/02_filereader.js b/op_crates/file/02_filereader.js index e398b23df3..b32cbfce93 100644 --- a/op_crates/file/02_filereader.js +++ b/op_crates/file/02_filereader.js @@ -14,6 +14,8 @@ ((window) => { const webidl = window.__bootstrap.webidl; + const { decode } = window.__bootstrap.encoding; + const { parseMimeType } = window.__bootstrap.mimesniff; const base64 = window.__bootstrap.base64; const state = Symbol("[[state]]"); @@ -33,9 +35,9 @@ /** * @param {Blob} blob - * @param {{kind: "ArrayBuffer" | "Text" | "DataUrl", encoding?: string}} readtype + * @param {{kind: "ArrayBuffer" | "Text" | "DataUrl" | "BinaryString", encoding?: string}} readtype */ - #readOperation = async (blob, readtype) => { + #readOperation = (blob, readtype) => { // 1. If fr’s state is "loading", throw an InvalidStateError DOMException. if (this[state] === "loading") { throw new DOMException( @@ -67,119 +69,156 @@ let isFirstChunk = true; // 10 in parallel while true - while (!this[aborted]) { - // 1. Wait for chunkPromise to be fulfilled or rejected. - try { - const chunk = await chunkPromise; - if (this[aborted]) return; + (async () => { + while (!this[aborted]) { + // 1. Wait for chunkPromise to be fulfilled or rejected. + try { + const chunk = await chunkPromise; + if (this[aborted]) return; - // 2. If chunkPromise is fulfilled, and isFirstChunk is true, queue a task to fire a progress event called loadstart at fr. - if (isFirstChunk) { - queueMicrotask(() => { - // fire a progress event for loadstart - const ev = new ProgressEvent("loadstart", {}); - this.dispatchEvent(ev); - }); - } - // 3. Set isFirstChunk to false. - isFirstChunk = false; - - // 4. If chunkPromise is fulfilled with an object whose done property is false - // and whose value property is a Uint8Array object, run these steps: - if (!chunk.done && chunk.value instanceof Uint8Array) { - chunks.push(chunk.value); - - // TODO(bartlomieju): (only) If roughly 50ms have passed since last progress - { - const size = chunks.reduce((p, i) => p + i.byteLength, 0); - const ev = new ProgressEvent("progress", { - loaded: size, + // 2. If chunkPromise is fulfilled, and isFirstChunk is true, queue a task to fire a progress event called loadstart at fr. + if (isFirstChunk) { + // TODO(lucacasonato): this is wrong, should be HTML "queue a task" + queueMicrotask(() => { + if (this[aborted]) return; + // fire a progress event for loadstart + const ev = new ProgressEvent("loadstart", {}); + this.dispatchEvent(ev); }); - this.dispatchEvent(ev); } + // 3. Set isFirstChunk to false. + isFirstChunk = false; - chunkPromise = reader.read(); - } // 5 Otherwise, if chunkPromise is fulfilled with an object whose done property is true, queue a task to run the following steps and abort this algorithm: - else if (chunk.done === true) { - queueMicrotask(() => { - // 1. Set fr’s state to "done". - this[state] = "done"; - // 2. Let result be the result of package data given bytes, type, blob’s type, and encodingName. - const size = chunks.reduce((p, i) => p + i.byteLength, 0); - const bytes = new Uint8Array(size); - let offs = 0; - for (const chunk of chunks) { - bytes.set(chunk, offs); - offs += chunk.byteLength; - } - switch (readtype.kind) { - case "ArrayBuffer": { - this[result] = bytes.buffer; - break; - } - case "Text": { - const decoder = new TextDecoder(readtype.encoding); - this[result] = decoder.decode(bytes.buffer); - break; - } - case "DataUrl": { - this[result] = "data:application/octet-stream;base64," + - base64.fromByteArray(bytes); - break; - } - } - // 4.2 Fire a progress event called load at the fr. + // 4. If chunkPromise is fulfilled with an object whose done property is false + // and whose value property is a Uint8Array object, run these steps: + if (!chunk.done && chunk.value instanceof Uint8Array) { + chunks.push(chunk.value); + + // TODO(bartlomieju): (only) If roughly 50ms have passed since last progress { - const ev = new ProgressEvent("load", { - lengthComputable: true, + const size = chunks.reduce((p, i) => p + i.byteLength, 0); + const ev = new ProgressEvent("progress", { loaded: size, - total: size, }); + // TODO(lucacasonato): this is wrong, should be HTML "queue a task" + queueMicrotask(() => { + if (this[aborted]) return; + this.dispatchEvent(ev); + }); + } + + chunkPromise = reader.read(); + } // 5 Otherwise, if chunkPromise is fulfilled with an object whose done property is true, queue a task to run the following steps and abort this algorithm: + else if (chunk.done === true) { + // TODO(lucacasonato): this is wrong, should be HTML "queue a task" + queueMicrotask(() => { + if (this[aborted]) return; + // 1. Set fr’s state to "done". + this[state] = "done"; + // 2. Let result be the result of package data given bytes, type, blob’s type, and encodingName. + const size = chunks.reduce((p, i) => p + i.byteLength, 0); + const bytes = new Uint8Array(size); + let offs = 0; + for (const chunk of chunks) { + bytes.set(chunk, offs); + offs += chunk.byteLength; + } + switch (readtype.kind) { + case "ArrayBuffer": { + this[result] = bytes.buffer; + break; + } + case "BinaryString": + this[result] = [...new Uint8Array(bytes.buffer)].map((v) => + String.fromCodePoint(v) + ).join(""); + break; + case "Text": { + let decoder = undefined; + if (readtype.encoding) { + try { + decoder = new TextDecoder(readtype.encoding); + } catch { + // don't care about the error + } + } + if (decoder === undefined) { + const mimeType = parseMimeType(blob.type); + if (mimeType) { + const charset = mimeType.parameters.get("charset"); + if (charset) { + try { + decoder = new TextDecoder(charset); + } catch { + // don't care about the error + } + } + } + } + if (decoder === undefined) { + decoder = new TextDecoder(); + } + this[result] = decode(bytes, decoder.encoding); + break; + } + case "DataUrl": { + const mediaType = blob.type || "application/octet-stream"; + this[result] = `data:${mediaType};base64,${ + base64.fromByteArray(bytes) + }`; + break; + } + } + // 4.2 Fire a progress event called load at the fr. + { + const ev = new ProgressEvent("load", { + lengthComputable: true, + loaded: size, + total: size, + }); + this.dispatchEvent(ev); + } + + // 5. If fr’s state is not "loading", fire a progress event called loadend at the fr. + //Note: Event handler for the load or error events could have started another load, if that happens the loadend event for this load is not fired. + if (this[state] !== "loading") { + const ev = new ProgressEvent("loadend", { + lengthComputable: true, + loaded: size, + total: size, + }); + this.dispatchEvent(ev); + } + }); + break; + } + } catch (err) { + // TODO(lucacasonato): this is wrong, should be HTML "queue a task" + queueMicrotask(() => { + if (this[aborted]) return; + + // chunkPromise rejected + this[state] = "done"; + this[error] = err; + + { + const ev = new ProgressEvent("error", {}); this.dispatchEvent(ev); } - // 5. If fr’s state is not "loading", fire a progress event called loadend at the fr. - //Note: Event handler for the load or error events could have started another load, if that happens the loadend event for this load is not fired. + //If fr’s state is not "loading", fire a progress event called loadend at fr. + //Note: Event handler for the error event could have started another load, if that happens the loadend event for this load is not fired. if (this[state] !== "loading") { - const ev = new ProgressEvent("loadend", { - lengthComputable: true, - loaded: size, - total: size, - }); + const ev = new ProgressEvent("loadend", {}); this.dispatchEvent(ev); } }); - break; } - } catch (err) { - if (this[aborted]) return; - - // chunkPromise rejected - this[state] = "done"; - this[error] = err; - - { - const ev = new ProgressEvent("error", {}); - this.dispatchEvent(ev); - } - - //If fr’s state is not "loading", fire a progress event called loadend at fr. - //Note: Event handler for the error event could have started another load, if that happens the loadend event for this load is not fired. - if (this[state] !== "loading") { - const ev = new ProgressEvent("loadend", {}); - this.dispatchEvent(ev); - } - - break; } - } + })(); }; - static EMPTY = 0; - static LOADING = 1; - static DONE = 2; - constructor() { super(); this[webidl.brand] = webidl.brand; @@ -254,7 +293,7 @@ const prefix = "Failed to execute 'readAsBinaryString' on 'FileReader'"; webidl.requiredArguments(arguments.length, 1, { prefix }); // alias for readAsArrayBuffer - this.#readOperation(blob, { kind: "ArrayBuffer" }); + this.#readOperation(blob, { kind: "BinaryString" }); } /** @param {Blob} blob */ @@ -285,6 +324,43 @@ } } + Object.defineProperty(FileReader, "EMPTY", { + writable: false, + enumerable: true, + configurable: false, + value: 0, + }); + Object.defineProperty(FileReader, "LOADING", { + writable: false, + enumerable: true, + configurable: false, + value: 1, + }); + Object.defineProperty(FileReader, "DONE", { + writable: false, + enumerable: true, + configurable: false, + value: 2, + }); + Object.defineProperty(FileReader.prototype, "EMPTY", { + writable: false, + enumerable: true, + configurable: false, + value: 0, + }); + Object.defineProperty(FileReader.prototype, "LOADING", { + writable: false, + enumerable: true, + configurable: false, + value: 1, + }); + Object.defineProperty(FileReader.prototype, "DONE", { + writable: false, + enumerable: true, + configurable: false, + value: 2, + }); + const handlerSymbol = Symbol("eventHandlers"); function makeWrappedHandler(handler) { @@ -302,7 +378,7 @@ // HTML specification section 8.1.5.1 Object.defineProperty(emitter, `on${name}`, { get() { - return this[handlerSymbol]?.get(name)?.handler; + return this[handlerSymbol]?.get(name)?.handler ?? null; }, set(value) { if (!this[handlerSymbol]) { diff --git a/op_crates/web/00_infra.js b/op_crates/web/00_infra.js new file mode 100644 index 0000000000..0590ffd035 --- /dev/null +++ b/op_crates/web/00_infra.js @@ -0,0 +1,31 @@ +// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. + +// @ts-check +/// +/// +/// + +"use strict"; + +((window) => { + /** + * https://infra.spec.whatwg.org/#collect-a-sequence-of-code-points + * @param {string} input + * @param {number} position + * @param {(char: string) => boolean} condition + * @returns {{result: string, position: number}} + */ + function collectSequenceOfCodepoints(input, position, condition) { + const start = position; + for ( + let c = input.charAt(position); + position < input.length && condition(c); + c = input.charAt(++position) + ); + return { result: input.slice(start, position), position }; + } + + window.__bootstrap.infra = { + collectSequenceOfCodepoints, + }; +})(globalThis); diff --git a/op_crates/web/01_mimesniff.js b/op_crates/web/01_mimesniff.js new file mode 100644 index 0000000000..918343f2cf --- /dev/null +++ b/op_crates/web/01_mimesniff.js @@ -0,0 +1,242 @@ +// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. + +// @ts-check +/// +/// +/// + +"use strict"; + +((window) => { + const { collectSequenceOfCodepoints } = window.__bootstrap.infra; + + const HTTP_TAB_OR_SPACE = ["\u0009", "\u0020"]; + const HTTP_WHITESPACE = ["\u000A", "\u000D", ...HTTP_TAB_OR_SPACE]; + + const ASCII_DIGIT = ["\u0030-\u0039"]; + const ASCII_UPPER_ALPHA = ["\u0041-\u005A"]; + const ASCII_LOWER_ALPHA = ["\u0061-\u007A"]; + const ASCII_ALPHA = [...ASCII_UPPER_ALPHA, ...ASCII_LOWER_ALPHA]; + const ASCII_ALPHANUMERIC = [...ASCII_DIGIT, ...ASCII_ALPHA]; + const HTTP_TOKEN_CODE_POINT = [ + "\u0021", + "\u0023", + "\u0025", + "\u0026", + "\u0027", + "\u002A", + "\u002B", + "\u002D", + "\u002E", + "\u005E", + "\u005F", + "\u0060", + "\u007C", + "\u007E", + ...ASCII_ALPHANUMERIC, + ]; + const HTTP_TOKEN_CODE_POINT_RE = new RegExp(`^[${HTTP_TOKEN_CODE_POINT}]+$`); + const HTTP_QUOTED_STRING_TOKEN_POINT = [ + "\u0009", + "\u0020-\u007E", + "\u0080-\u00FF", + ]; + const HTTP_QUOTED_STRING_TOKEN_POINT_RE = new RegExp( + `^[${HTTP_QUOTED_STRING_TOKEN_POINT}]+$`, + ); + + /** + * https://fetch.spec.whatwg.org/#collect-an-http-quoted-string + * @param {string} input + * @param {number} position + * @param {boolean} extractValue + * @returns {{result: string, position: number}} + */ + function collectHttpQuotedString(input, position, extractValue) { + // 1. + const positionStart = position; + // 2. + let value = ""; + // 3. + if (input[position] !== "\u0022") throw new Error('must be "'); + // 4. + position++; + // 5. + while (true) { + // 5.1. + const res = collectSequenceOfCodepoints( + input, + position, + (c) => c !== "\u0022" && c !== "\u005C", + ); + value += res.result; + position = res.position; + // 5.2. + if (position >= input.length) break; + // 5.3. + const quoteOrBackslash = input[position]; + // 5.4. + position++; + // 5.5. + if (quoteOrBackslash === "\u005C") { + // 5.5.1. + if (position >= input.length) { + value += "\u005C"; + break; + } + // 5.5.2. + value += input[position]; + // 5.5.3. + position++; + } else { // 5.6. + // 5.6.1 + if (input[position] !== "\u0022") throw new Error('must be "'); + // 5.6.2 + break; + } + } + // 6. + if (extractValue) return { result: value, position }; + // 7. + return { result: input.substring(positionStart, position + 1), position }; + } + + /** + * @param {string} input + */ + function parseMimeType(input) { + // 1. + input = input.replaceAll(new RegExp(`^[${HTTP_WHITESPACE}]+`, "g"), ""); + input = input.replaceAll(new RegExp(`[${HTTP_WHITESPACE}]+$`, "g"), ""); + + // 2. + let position = 0; + const endOfInput = input.length; + + // 3. + const res1 = collectSequenceOfCodepoints( + input, + position, + (c) => c != "\u002F", + ); + const type = res1.result; + position = res1.position; + + // 4. + if (type === "" || !HTTP_TOKEN_CODE_POINT_RE.test(type)) { + return null; + } + + // 5. + if (position >= endOfInput) return null; + + // 6. + position++; + + // 7. + const res2 = collectSequenceOfCodepoints( + input, + position, + (c) => c != "\u003B", + ); + let subtype = res2.result; + position = res2.position; + + // 8. + subtype = subtype.replaceAll(new RegExp(`[${HTTP_WHITESPACE}]+$`, "g"), ""); + + // 9. + if (subtype === "" || !HTTP_TOKEN_CODE_POINT_RE.test(subtype)) { + return null; + } + + // 10. + const mimeType = { + type: type.toLowerCase(), + subtype: subtype.toLowerCase(), + /** @type {Map} */ + parameters: new Map(), + }; + + // 11. + while (position < endOfInput) { + // 11.1. + position++; + + // 11.2. + const res1 = collectSequenceOfCodepoints( + input, + position, + (c) => HTTP_WHITESPACE.includes(c), + ); + position = res1.position; + + // 11.3. + const res2 = collectSequenceOfCodepoints( + input, + position, + (c) => c !== "\u003B" && c !== "\u003D", + ); + let parameterName = res2.result; + position = res2.position; + + // 11.4. + parameterName = parameterName.toLowerCase(); + + // 11.5. + if (position < endOfInput) { + if (input[position] == "\u003B") continue; + position++; + } + + // 11.6. + if (position >= endOfInput) break; + + // 11.7. + let parameterValue = null; + + // 11.8. + if (input[position] == "\u0022") { + // 11.8.1. + const res = collectHttpQuotedString(input, position, true); + parameterValue = res.result; + position = res.position; + + // 11.8.2. + position++; + } else { // 11.9. + // 11.9.1. + const res = collectSequenceOfCodepoints( + input, + position, + (c) => c !== "\u003B", + ); + parameterValue = res.result; + position = res.position; + + // 11.9.2. + parameterValue = parameterValue.replaceAll( + new RegExp(`[${HTTP_WHITESPACE}]+$`, "g"), + "", + ); + + // 11.9.3. + if (parameterValue === "") continue; + } + + // 11.9. + if ( + parameterName !== "" && HTTP_TOKEN_CODE_POINT_RE.test(parameterName) && + HTTP_QUOTED_STRING_TOKEN_POINT_RE.test(parameterValue) && + !mimeType.parameters.has(parameterName) + ) { + mimeType.parameters.set(parameterName, parameterValue); + } + } + + // 12. + return mimeType; + } + + window.__bootstrap.mimesniff = { parseMimeType }; +})(this); diff --git a/op_crates/web/08_text_encoding.js b/op_crates/web/08_text_encoding.js index 73cb38311c..1fda1a8167 100644 --- a/op_crates/web/08_text_encoding.js +++ b/op_crates/web/08_text_encoding.js @@ -4545,10 +4545,38 @@ fromByteArray, }; + /** + * @param {Uint8Array} bytes + */ + function decode(bytes, encoding) { + const BOMEncoding = BOMSniff(bytes); + let start = 0; + if (BOMEncoding !== null) { + encoding = BOMEncoding; + if (BOMEncoding === "UTF-8") start = 3; + else start = 2; + } + return new TextDecoder(encoding).decode(bytes.slice(start)); + } + + /** + * @param {Uint8Array} bytes + */ + function BOMSniff(bytes) { + const BOM = bytes.subarray(0, 3); + if (BOM[0] === 0xEF && BOM[1] === 0xBB && BOM[2] === 0xBF) { + return "UTF-8"; + } + if (BOM[0] === 0xFE && BOM[1] === 0xFF) return "UTF-16BE"; + if (BOM[0] === 0xFF && BOM[1] === 0xFE) return "UTF-16LE"; + return null; + } + window.TextEncoder = TextEncoder; window.TextDecoder = TextDecoder; window.atob = atob; window.btoa = btoa; window.__bootstrap = window.__bootstrap || {}; + window.__bootstrap.encoding = { decode }; window.__bootstrap.base64 = base64; })(this); diff --git a/op_crates/web/internal.d.ts b/op_crates/web/internal.d.ts index 521563810b..18220b08e6 100644 --- a/op_crates/web/internal.d.ts +++ b/op_crates/web/internal.d.ts @@ -5,6 +5,25 @@ declare namespace globalThis { declare namespace __bootstrap { + declare var infra: { + collectSequenceOfCodepoints( + input: string, + position: number, + condition: (char: string) => boolean, + ): { + result: string; + position: number; + }; + }; + + declare var mimesniff: { + parseMimeType(input: string): { + type: string; + subtype: string; + parameters: Map; + } | null; + }; + declare var eventTarget: { EventTarget: typeof EventTarget; }; diff --git a/op_crates/web/lib.rs b/op_crates/web/lib.rs index 8ee944d74c..a609dc4cd3 100644 --- a/op_crates/web/lib.rs +++ b/op_crates/web/lib.rs @@ -6,10 +6,18 @@ use std::path::PathBuf; /// Load and execute the javascript code. pub fn init(isolate: &mut JsRuntime) { let files = vec![ + ( + "deno:op_crates/web/00_infra.js", + include_str!("00_infra.js"), + ), ( "deno:op_crates/web/01_dom_exception.js", include_str!("01_dom_exception.js"), ), + ( + "deno:op_crates/web/01_mimesniff.js", + include_str!("01_mimesniff.js"), + ), ( "deno:op_crates/web/02_event.js", include_str!("02_event.js"), diff --git a/test_util/wpt b/test_util/wpt index f897da0087..a522daf78a 160000 --- a/test_util/wpt +++ b/test_util/wpt @@ -1 +1 @@ -Subproject commit f897da00871cf39366bc2f0ceec051c65bc75703 +Subproject commit a522daf78a71c2252d10c978f09cf0575aceb794 diff --git a/tools/wpt/expectation.json b/tools/wpt/expectation.json index 4f04213aa3..632af480a5 100644 --- a/tools/wpt/expectation.json +++ b/tools/wpt/expectation.json @@ -787,6 +787,29 @@ "fileReader.any.js": true, "url": { "url-format.any.js": true + }, + "reading-data-section": { + "Determining-Encoding.any.js": true, + "FileReader-event-handler-attributes.any.js": true, + "FileReader-multiple-reads.any.js": true, + "filereader_abort.any.js": true, + "filereader_error.any.js": true, + "filereader_events.any.js": false, + "filereader_readAsArrayBuffer.any.js": true, + "filereader_readAsBinaryString.any.js": true, + "filereader_readAsDataURL.any.js": true, + "filereader_readAsText.any.js": true, + "filereader_readystate.any.js": true, + "filereader_result.any.js": [ + "result is null during \"loadstart\" event for readAsText", + "result is null during \"loadstart\" event for readAsDataURL", + "result is null during \"loadstart\" event for readAsArrayBuffer", + "result is null during \"loadstart\" event for readAsBinaryString", + "result is null during \"progress\" event for readAsText", + "result is null during \"progress\" event for readAsDataURL", + "result is null during \"progress\" event for readAsArrayBuffer", + "result is null during \"progress\" event for readAsBinaryString" + ] } }, "html": {