Skip to content

Commit

Permalink
fix: Handle data: URIs more consistently (#19)
Browse files Browse the repository at this point in the history
1. No longer requires `;base64` to be included on `data:` URIs.
2. Supports immediately aborting `data:` URIs.

Fix: #18
  • Loading branch information
isaacs committed Mar 1, 2022
1 parent 8aff18a commit 3a11fe4
Show file tree
Hide file tree
Showing 2 changed files with 89 additions and 16 deletions.
48 changes: 33 additions & 15 deletions lib/index.js
Expand Up @@ -15,23 +15,41 @@ const { getNodeRequestOptions } = Request
const FetchError = require('./fetch-error.js')
const AbortError = require('./abort-error.js')

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, {
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))
}
// 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 } = new URL(url)
const split = pathname.split(',')
if (split.length < 2) {
throw new Error('invalid data: URI')
}
const mime = split.shift()
const base64 = /;base64$/.test(mime)
type = base64 ? mime.slice(0, -1 * ';base64'.length) : mime
const rawData = decodeURIComponent(split.join(',') + search)
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.'))
}

const headers = { 'Content-Length': data.length }
if (type) {
headers['Content-Type'] = type
}
return resolve(new Response(data, { headers }))
}))
}

return new Promise((resolve, reject) => {
Expand Down
57 changes: 56 additions & 1 deletion test/index.js
Expand Up @@ -1872,12 +1872,67 @@ t.test('data uri', t => {

t.test('reject invalid data uri', t =>
t.rejects(fetch(invalidDataUrl), {
message: 'invalid URL',
message: 'invalid data: URI',
}))

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.test('data uri with no type specified', t =>
fetch('data:,hello,%20world!').then(r => {
t.equal(r.status, 200)
t.equal(r.headers.get('Content-Type'), null)
return r.buffer().then(b => t.equal(b.toString(), 'hello, world!'))
}))

t.test('search included, hash not included', t =>
fetch('data:,hello?with=search#no%20hash').then(r => {
t.equal(r.status, 200)
t.equal(r.headers.get('Content-Type'), null)
return r.buffer().then(b => t.equal(b.toString(), 'hello?with=search'))
}))

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',
Expand Down

0 comments on commit 3a11fe4

Please sign in to comment.