Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions docs/docs/api/Dispatcher.md
Original file line number Diff line number Diff line change
Expand Up @@ -1275,6 +1275,60 @@ All deduplicated requests receive the complete response including status code, h

For observability, request deduplication events are published to the `undici:request:pending-requests` [diagnostic channel](/docs/docs/api/DiagnosticsChannel.md#undicirequestpending-requests).

##### `file`

The `file` interceptor allows a dispatcher to serve `file:` URLs.

By default, Undici `fetch()` does not read `file:` URLs. This interceptor is an explicit opt-in mechanism that lets applications define their own policy.

**Security model**

- Deny by default.
- You must provide an `allow()` policy callback.
- No reliance on Node's process permission model.

**Options**

- `allow({ path, url, method, opts })` - Policy callback. Must return `true` to allow access. Default denies all paths.
- `resolvePath(url)` - Converts a `file:` URL into a filesystem path. Default: `node:url.fileURLToPath`.
- `read(path)` - Reads file content. Default: `node:fs/promises.readFile`.
- `contentType({ path, url, method, opts })` - Optional callback to set a `content-type` header.

**Example - Enable file URLs for a specific directory with `fetch`**

```js
const { Agent, fetch, interceptors } = require('undici')

const root = '/srv/static'

const dispatcher = new Agent().compose(interceptors.file({
allow: ({ path }) => path.startsWith(root)
}))

const response = await fetch(new URL('file:///srv/static/readme.txt'), {
dispatcher
})

console.log(await response.text())
```

**Example - Custom content type resolver**

```js
const nodePath = require('node:path')
const { Agent, request, interceptors } = require('undici')

const dispatcher = new Agent().compose(interceptors.file({
allow: ({ path }) => path.endsWith('.json'),
contentType: ({ path }) => {
if (nodePath.extname(path) === '.json') return 'application/json'
}
}))

const { body } = await request('file:///tmp/data.json', { dispatcher })
console.log(await body.text())
```

## Instance Events

### Event: `'connect'`
Expand Down
3 changes: 2 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ module.exports.interceptors = {
dns: require('./lib/interceptor/dns'),
cache: require('./lib/interceptor/cache'),
decompress: require('./lib/interceptor/decompress'),
deduplicate: require('./lib/interceptor/deduplicate')
deduplicate: require('./lib/interceptor/deduplicate'),
file: require('./lib/interceptor/file')
}

module.exports.cacheStores = {
Expand Down
181 changes: 181 additions & 0 deletions lib/interceptor/file.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
'use strict'

const { readFile } = require('node:fs/promises')
const { fileURLToPath } = require('node:url')

function createAbortController () {
let aborted = false
let reason = null

return {
resume () {},
pause () {},
get paused () {
return false
},
get aborted () {
return aborted
},
get reason () {
return reason
},
abort (err) {
if (aborted) {
return
}

aborted = true
reason = err ?? new Error('Request aborted')
}
}
}

function toFileURL (opts) {
if (opts == null || typeof opts !== 'object') {
return null
}

if (opts.origin != null) {
try {
const origin = opts.origin instanceof URL ? opts.origin : new URL(String(opts.origin))
if (origin.protocol === 'file:') {
return new URL(opts.path ?? '', origin)
}
} catch {
// Ignore invalid origin and try path.
}
}

if (typeof opts.path === 'string' && opts.path.startsWith('file:')) {
try {
return new URL(opts.path)
} catch {
return null
}
}

return null
}

function toRawHeaders (headers) {
const rawHeaders = []
for (const [name, value] of Object.entries(headers)) {
rawHeaders.push(Buffer.from(name), Buffer.from(String(value)))
}
return rawHeaders
}

/**
* @param {import('../../types/interceptors').FileInterceptorOpts} [opts]
*/
function createFileInterceptor (opts = {}) {
const {
allow = () => false,
contentType,
read = readFile,
resolvePath = fileURLToPath
} = opts

if (typeof allow !== 'function') {
throw new TypeError('file interceptor: opts.allow must be a function')
}

if (contentType != null && typeof contentType !== 'function') {
throw new TypeError('file interceptor: opts.contentType must be a function')
}

if (typeof read !== 'function') {
throw new TypeError('file interceptor: opts.read must be a function')
}

if (typeof resolvePath !== 'function') {
throw new TypeError('file interceptor: opts.resolvePath must be a function')
}

return dispatch => {
return function fileInterceptorDispatch (dispatchOpts, handler) {
const fileURL = toFileURL(dispatchOpts)
if (!fileURL) {
return dispatch(dispatchOpts, handler)
}

const controller = createAbortController()

try {
handler.onConnect?.((err) => controller.abort(err))
handler.onRequestStart?.(controller, null)
} catch (err) {
handler.onResponseError?.(controller, err)
handler.onError?.(err)
return true
}

if (controller.aborted) {
return true
}

;(async () => {
try {
const method = String(dispatchOpts.method || 'GET').toUpperCase()
if (method !== 'GET' && method !== 'HEAD') {
throw new TypeError(`Method ${method} is not supported for file URLs.`)
}

const path = resolvePath(fileURL)
const allowed = await allow({ path, url: fileURL, method, opts: dispatchOpts })
if (!allowed) {
throw new Error(`Access to ${fileURL.href} is not allowed by file interceptor.`)
}

const fileContent = await read(path)
const chunk = Buffer.isBuffer(fileContent) ? fileContent : Buffer.from(fileContent)

const headers = {
'content-length': String(chunk.length)
}

if (contentType) {
const value = await contentType({ path, url: fileURL, method, opts: dispatchOpts })
if (typeof value === 'string' && value.length > 0) {
headers['content-type'] = value
}
}

if (typeof handler.onResponseStart === 'function') {
handler.onResponseStart(controller, 200, headers, 'OK')
} else {
if (typeof handler.onHeaders === 'function') {
handler.onHeaders(200, toRawHeaders(headers), () => {}, 'OK')
}
}

if (!controller.aborted && method !== 'HEAD') {
if (typeof handler.onResponseData === 'function') {
handler.onResponseData(controller, chunk)
} else {
handler.onData?.(chunk)
}
}

if (!controller.aborted) {
if (typeof handler.onResponseEnd === 'function') {
handler.onResponseEnd(controller, {})
} else {
handler.onComplete?.([])
}
}
} catch (err) {
if (typeof handler.onResponseError === 'function') {
handler.onResponseError(controller, err)
} else {
handler.onError?.(err)
}
}
})()

return true
}
}
}

module.exports = createFileInterceptor
11 changes: 6 additions & 5 deletions lib/web/fetch/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -951,9 +951,9 @@ function schemeFetch (fetchParams) {
}))
}
case 'file:': {
// For now, unfortunate as it is, file URLs are left as an exercise for the reader.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment comes from the spec and should be kept.

// When in doubt, return a network error.
return Promise.resolve(makeNetworkError('not implemented... yet...'))
// file:// can be handled by custom dispatchers/interceptors.
return httpFetch(fetchParams)
.catch((err) => makeNetworkError(err))
}
case 'http:':
case 'https:': {
Expand Down Expand Up @@ -2132,13 +2132,14 @@ async function httpNetworkFetch (
/** @type {import('../../..').Agent} */
const agent = fetchParams.controller.dispatcher

const isFileURL = url.protocol === 'file:'
const path = url.pathname + url.search
const hasTrailingQuestionMark = url.search.length === 0 && url.href[url.href.length - url.hash.length - 1] === '?'

return new Promise((resolve, reject) => agent.dispatch(
{
path: hasTrailingQuestionMark ? `${path}?` : path,
origin: url.origin,
path: isFileURL ? url.href : (hasTrailingQuestionMark ? `${path}?` : path),
origin: isFileURL ? undefined : url.origin,
method: request.method,
body: agent.isMockActive ? request.body && (request.body.source || request.body.stream) : body,
headers: request.headersList.entries,
Expand Down
48 changes: 48 additions & 0 deletions test/fetch/file-url-interceptor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
'use strict'

const { test } = require('node:test')
const assert = require('node:assert')
const { mkdtemp, writeFile, rm } = require('node:fs/promises')
const { join } = require('node:path')
const { tmpdir } = require('node:os')
const { pathToFileURL } = require('node:url')

const { Agent, fetch, interceptors } = require('../..')

test('fetch() rejects file URLs by default', async () => {
const fileURL = pathToFileURL(__filename)

await assert.rejects(fetch(fileURL), new TypeError('fetch failed'))
})

test('fetch() can read file URLs through a custom file interceptor', async (t) => {
const dir = await mkdtemp(join(tmpdir(), 'undici-fetch-file-url-'))
const filePath = join(dir, 'message.txt')
await writeFile(filePath, 'hello from file interceptor')

const dispatcher = new Agent().compose(interceptors.file({
allow: ({ path }) => path.startsWith(dir)
}))

t.after(async () => {
await dispatcher.close()
await rm(dir, { recursive: true, force: true })
})

const response = await fetch(pathToFileURL(filePath), { dispatcher })

assert.equal(response.status, 200)
assert.equal(await response.text(), 'hello from file interceptor')
})

test('fetch() with file interceptor rejects disallowed paths', async (t) => {
const dispatcher = new Agent().compose(interceptors.file({
allow: () => false
}))

t.after(async () => {
await dispatcher.close()
})

await assert.rejects(fetch(pathToFileURL(__filename), { dispatcher }), new TypeError('fetch failed'))
})
Loading
Loading