From 285bfef7119074453283db37fcf786027fbe1804 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Mon, 19 Oct 2026 14:40:46 +0000 Subject: [PATCH] feat: add onFile hook --- README.md | 8 ++ lib/send.js | 15 ++- test/onFile.test.js | 247 ++++++++++++++++++++++++++++++++++++++++++++ types/index.d.ts | 10 +- types/index.tst.ts | 8 +- 5 files changed, 283 insertions(+), 5 deletions(-) create mode 100644 test/onFile.test.js diff --git a/README.md b/README.md index e78ae45..dc1b08d 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/lib/send.js b/lib/send.js index 975125b..645e766 100644 --- a/lib/send.js +++ b/lib/send.js @@ -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 @@ -158,6 +166,7 @@ function normalizeOptions (options) { lastModified, maxage, maxContentRangeChunkSize, + onFile, root, highWaterMark, start: options.start, @@ -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 diff --git a/test/onFile.test.js b/test/onFile.test.js new file mode 100644 index 0000000..ccf4bcf --- /dev/null +++ b/test/onFile.test.js @@ -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' + } + ) +}) diff --git a/types/index.d.ts b/types/index.d.ts index 36184e1..4bd2e83 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -4,7 +4,7 @@ /// -import { Dirent } from 'node:fs' +import { Stats } from 'node:fs' import * as stream from 'node:stream' /** @@ -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) | undefined; + /** * Serve files relative to path. */ @@ -138,7 +144,7 @@ declare namespace send { type: 'file' metadata: { path: string - stat: Dirent + stat: Stats } } diff --git a/types/index.tst.ts b/types/index.tst.ts index 032a54f..9336281 100644 --- a/types/index.tst.ts +++ b/types/index.tst.ts @@ -1,4 +1,4 @@ -import { Dirent } from 'node:fs' +import { Stats } from 'node:fs' import { resolve } from 'node:path' import { Readable } from 'node:stream' import { expect } from 'tstyche' @@ -6,6 +6,7 @@ import send, { DirectorySendResult, ErrorSendResult, FileSendResult, + SendOptions, SendResult } from '..' @@ -15,6 +16,9 @@ send.mime.define({ expect(send.isUtf8MimeType).type.toBe<(value: string) => boolean>() expect(send.isUtf8MimeType('application/json')).type.toBe() +expect().type.toBe< + ((path: string, stat: Stats) => void | Promise) | undefined +>() const req: any = {}; @@ -53,7 +57,7 @@ const req: any = {}; case 'file': { expect(result).type.toBe() expect(result.metadata.path).type.toBe() - expect(result.metadata.stat).type.toBe() + expect(result.metadata.stat).type.toBe() break } case 'directory': {