diff --git a/lib/index.js b/lib/index.js index 2ffcba8..146f599 100644 --- a/lib/index.js +++ b/lib/index.js @@ -17,23 +17,41 @@ const AbortError = require('./abort-error.js') const resolveUrl = Url.resolve -const fetch = (url, opts) => { +// XXX this should really be split up and unit-ized for easier testing +// and better DRY implementation of data/http request aborting +const fetch = async (url, opts) => { if (/^data:/.test(url)) { const request = new Request(url, opts) - try { - const split = url.split(',') - const data = Buffer.from(split[1], 'base64') - const type = split[0].match(/^data:(.*);base64$/)[1] - return Promise.resolve(new Response(data, { + // delay 1 promise tick so that the consumer can abort right away + return Promise.resolve().then(() => new Promise((resolve, reject) => { + let type, data + try { + const { pathname, search, hash } = new Url.URL(url) + const split = pathname.split(',') + if (split.length < 2) { + throw new Error('content-type required for data: URIs') + } + const mime = split.shift() + const base64 = /;base64$/.test(mime) + type = base64 ? mime.slice(0, -1 * ';base64'.length) : mime + const rawData = split.join(',') + search + hash + data = base64 ? Buffer.from(rawData, 'base64') : Buffer.from(rawData) + } catch (er) { + return reject(new FetchError(`[${request.method}] ${ + request.url} invalid URL, ${er.message}`, 'system', er)) + } + + const { signal } = request + if (signal && signal.aborted) + return reject(new AbortError('The user aborted a request.')) + + return resolve(new Response(data, { headers: { 'Content-Type': type, 'Content-Length': data.length, - } + }, })) - } catch (er) { - return Promise.reject(new FetchError(`[${request.method}] ${ - request.url} invalid URL, ${er.message}`, 'system', er)) - } + })) } return new Promise((resolve, reject) => { diff --git a/test/index.js b/test/index.js index 64bd8e7..f392505 100644 --- a/test/index.js +++ b/test/index.js @@ -1868,12 +1868,53 @@ t.test('data uri', t => { t.test('reject invalid data uri', t => t.rejects(fetch(invalidDataUrl), { - message: 'invalid URL', + message: 'content-type required for data: URIs', + })) + + t.test('data uri not base64 encoded', t => + fetch('data:text/plain,hello, world!').then(r => { + t.equal(r.status, 200) + t.equal(r.headers.get('Content-Type'), 'text/plain') + return r.buffer().then(b => t.equal(b.toString(), 'hello, world!')) })) t.end() }) +t.test('aborting data uris', t => { + const controllers = [AbortController, AbortController2] + t.plan(controllers.length) + const url = 'data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==' + controllers.forEach((Controller, idx) => { + t.test(`controller ${idx}`, async t => { + t.test('pre-abort', async t => { + const controller = new Controller() + controller.abort() + t.rejects(fetch(url, { signal: controller.signal }), { + message: 'The user aborted a request.', + }) + }) + + t.test('post-abort', async t => { + const controller = new Controller() + t.rejects(fetch(url, { signal: controller.signal }), { + message: 'The user aborted a request.', + }) + controller.abort() + }) + + t.test('cannot abort after first tick', t => { + const controller = new Controller() + t.resolves(fetch(url, { signal: controller.signal })) + Promise.resolve().then(() => { + controller.abort() + t.end() + }) + }) + }) + }) +}) + t.test('redirect changes host header', t => fetch(`http://127.0.0.1:${local.port}/host-redirect`, { redirect: 'follow',