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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,14 @@ This can also be a string accepted by the
Specify the maximum response content size, defaults to the entire file size.
This will be used when `acceptRanges` is true.

##### onFile

Called with the final selected file's absolute path and file system statistics
after file resolution, but before response processing and file stream creation.
The callback may be synchronous or return a promise. If it returns a promise,
`send` waits for it to settle. If the callback throws or its returned promise
rejects, the operation is aborted and `send()` rejects.

##### root

Serve files relative to `path`.
Expand Down
15 changes: 14 additions & 1 deletion lib/send.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,14 @@ function normalizeOptions (options) {
? Number(options.maxContentRangeChunkSize)
: null

let onFile = null
if (options.onFile !== undefined) {
if (typeof options.onFile !== 'function') {
throw new TypeError('onFile option must be a function')
}
onFile = options.onFile
}

const root = options.root
? resolve(options.root)
: null
Expand All @@ -158,6 +166,7 @@ function normalizeOptions (options) {
lastModified,
maxage,
maxContentRangeChunkSize,
onFile,
root,
highWaterMark,
start: options.start,
Expand Down Expand Up @@ -465,7 +474,11 @@ function sendNotModified (headers, path, stat) {
}
}

function sendFileDirectly (request, path, stat, options) {
async function sendFileDirectly (request, path, stat, options) {
if (options.onFile !== null) {
await options.onFile(path, stat)
}

let len = stat.size
let offset = options.start ?? 0

Expand Down
247 changes: 247 additions & 0 deletions test/onFile.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
'use strict'

const { test } = require('node:test')
const fs = require('node:fs')
const path = require('node:path')
const send = require('..')

const fixtures = path.join(__dirname, 'fixtures')

function createRequest (method = 'GET', headers = {}) {
return { method, headers }
}

function destroyStream (result) {
result.stream.destroy()
}

test('onFile receives the final selected file', async function (t) {
const cases = [
{
name: 'direct file',
requestPath: '/name.txt',
options: {},
expectedPath: path.join(fixtures, 'name.txt')
},
{
name: 'default index',
requestPath: '/pets/',
options: {},
expectedPath: path.join(fixtures, 'pets', 'index.html')
},
{
name: 'custom index',
requestPath: '/',
options: { index: ['missing.html', 'pets', 'tobi.html'] },
expectedPath: path.join(fixtures, 'tobi.html')
},
{
name: 'extension fallback',
requestPath: '/name',
options: { extensions: ['missing', 'dir', 'html'] },
expectedPath: path.join(fixtures, 'name.html')
}
]

for (const testCase of cases) {
await t.test(testCase.name, async function (t) {
let calls = 0
let selectedPath
let selectedStat
const result = await send(createRequest(), testCase.requestPath, {
...testCase.options,
root: fixtures,
onFile (filePath, stat) {
calls++
selectedPath = filePath
selectedStat = stat
}
})

t.assert.strictEqual(calls, 1)
t.assert.strictEqual(selectedPath, testCase.expectedPath)
t.assert.strictEqual(selectedStat, result.metadata.stat)
t.assert.ok(selectedStat instanceof fs.Stats)
destroyStream(result)
})
}
})

test('onFile is not called for directory or error results', async function (t) {
let calls = 0
const options = {
root: fixtures,
onFile () {
calls++
}
}

const directoryResult = await send(createRequest(), '/pets', options)
t.assert.strictEqual(directoryResult.type, 'directory')
destroyStream(directoryResult)

const errorResult = await send(createRequest(), '/missing.txt', options)
t.assert.strictEqual(errorResult.type, 'error')
destroyStream(errorResult)

t.assert.strictEqual(calls, 0)
})

test('onFile waits before creating the file stream', { timeout: 5000 }, async function (t) {
const originalCreateReadStream = fs.createReadStream
const events = []
let releaseHook
let markHookStarted
const hookStarted = new Promise((resolve) => {
markHookStarted = resolve
})
const hookGate = new Promise((resolve) => {
releaseHook = resolve
})

fs.createReadStream = function (...args) {
events.push('stream')
return originalCreateReadStream.apply(this, args)
}

try {
let settled = false
const pending = send(createRequest(), '/name.txt', {
root: fixtures,
async onFile () {
events.push('hook start')
markHookStarted()
await hookGate
events.push('hook end')
}
}).then((result) => {
settled = true
return result
})

await hookStarted
await Promise.resolve()
t.assert.strictEqual(settled, false)
t.assert.deepStrictEqual(events, ['hook start'])

releaseHook()
const result = await pending
t.assert.deepStrictEqual(events, ['hook start', 'hook end', 'stream'])
destroyStream(result)
} finally {
fs.createReadStream = originalCreateReadStream
}
})

test('onFile errors reject send unchanged before stream creation', async function (t) {
const originalCreateReadStream = fs.createReadStream
let streamCalls = 0

fs.createReadStream = function (...args) {
streamCalls++
return originalCreateReadStream.apply(this, args)
}

try {
const thrownError = new Error('thrown by onFile')
await t.assert.rejects(
send(createRequest(), '/name.txt', {
root: fixtures,
onFile () {
throw thrownError
}
}),
(error) => error === thrownError
)

const rejectedError = new Error('rejected by onFile')
await t.assert.rejects(
send(createRequest(), '/name.txt', {
root: fixtures,
onFile () {
return Promise.reject(rejectedError)
}
}),
(error) => error === rejectedError
)

t.assert.strictEqual(streamCalls, 0)
} finally {
fs.createReadStream = originalCreateReadStream
}
})

test('onFile is called for ranged GET requests', async function (t) {
let calls = 0
const result = await send(
createRequest('GET', { range: 'bytes=0-4' }),
'/nums.txt',
{
root: fixtures,
onFile (filePath) {
calls++
t.assert.strictEqual(filePath, path.join(fixtures, 'nums.txt'))
}
}
)

t.assert.strictEqual(calls, 1)
t.assert.strictEqual(result.statusCode, 206)
t.assert.strictEqual(result.headers['Content-Range'], 'bytes 0-4/9')

let body = ''
for await (const chunk of result.stream) {
body += chunk
}
t.assert.strictEqual(body, '12345')
})

test('onFile is called for HEAD and conditional requests', async function (t) {
const originalCreateReadStream = fs.createReadStream
let streamCalls = 0
const methods = []

fs.createReadStream = function (...args) {
streamCalls++
return originalCreateReadStream.apply(this, args)
}

try {
const headResult = await send(createRequest('HEAD'), '/name.txt', {
root: fixtures,
onFile () {
methods.push('HEAD')
}
})
t.assert.strictEqual(headResult.statusCode, 200)
destroyStream(headResult)

const conditionalResult = await send(
createRequest('GET', { 'if-none-match': '*' }),
'/name.txt',
{
root: fixtures,
onFile () {
methods.push('conditional')
}
}
)
t.assert.strictEqual(conditionalResult.statusCode, 304)
destroyStream(conditionalResult)

t.assert.deepStrictEqual(methods, ['HEAD', 'conditional'])
t.assert.strictEqual(streamCalls, 0)
} finally {
fs.createReadStream = originalCreateReadStream
}
})

test('onFile must be a function when provided', async function (t) {
await t.assert.rejects(
send(createRequest(), '/name.txt', { root: fixtures, onFile: null }),
{
name: 'TypeError',
message: 'onFile option must be a function'
}
)
})
10 changes: 8 additions & 2 deletions types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

/// <reference types="node" />

import { Dirent } from 'node:fs'
import { Stats } from 'node:fs'
import * as stream from 'node:stream'

/**
Expand Down Expand Up @@ -110,6 +110,12 @@ declare namespace send {
*/
maxContentRangeChunkSize?: number | undefined;

/**
* Called with the selected file's absolute path and file system statistics before response processing and stream creation.
* The callback may return a promise, which will be awaited.
*/
onFile?: ((path: string, stat: Stats) => void | Promise<void>) | undefined;

/**
* Serve files relative to path.
*/
Expand Down Expand Up @@ -138,7 +144,7 @@ declare namespace send {
type: 'file'
metadata: {
path: string
stat: Dirent
stat: Stats
}
}

Expand Down
8 changes: 6 additions & 2 deletions types/index.tst.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Dirent } from 'node:fs'
import { Stats } from 'node:fs'
import { resolve } from 'node:path'
import { Readable } from 'node:stream'
import { expect } from 'tstyche'
import send, {
DirectorySendResult,
ErrorSendResult,
FileSendResult,
SendOptions,
SendResult
} from '..'

Expand All @@ -15,6 +16,9 @@ send.mime.define({

expect(send.isUtf8MimeType).type.toBe<(value: string) => boolean>()
expect(send.isUtf8MimeType('application/json')).type.toBe<boolean>()
expect<SendOptions['onFile']>().type.toBe<
((path: string, stat: Stats) => void | Promise<void>) | undefined
>()

const req: any = {};

Expand Down Expand Up @@ -53,7 +57,7 @@ const req: any = {};
case 'file': {
expect(result).type.toBe<FileSendResult>()
expect(result.metadata.path).type.toBe<string>()
expect(result.metadata.stat).type.toBe<Dirent>()
expect(result.metadata.stat).type.toBe<Stats>()
break
}
case 'directory': {
Expand Down
Loading