diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index bcc3b5a..0000000 --- a/.eslintignore +++ /dev/null @@ -1,3 +0,0 @@ -node_modules/* -test/directory/* -test/file/* \ No newline at end of file diff --git a/.github/workflows/ci-plugin.yml b/.github/workflows/ci-plugin.yml index 24d0ea4..25116d4 100644 --- a/.github/workflows/ci-plugin.yml +++ b/.github/workflows/ci-plugin.yml @@ -10,4 +10,4 @@ on: jobs: test: - uses: hapijs/.github/.github/workflows/ci-plugin.yml@min-node-18-hapi-21 + uses: hapijs/.github/.github/workflows/ci-plugin.yml@min-node-20-hapi-21 diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..08ba191 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from 'eslint/config'; +import HapiEslintPlugin from '@hapi/eslint-plugin'; + +export default defineConfig([ + ...HapiEslintPlugin.configs.module, + { + languageOptions: { + globals: { + Crypto: 'off', + File: 'off' + } + } + }, + globalIgnores([ + 'test/directory/*', + 'test/file/*' + ]) +]); diff --git a/lib/etag.js b/lib/etag.js index 559541b..e27c2ea 100755 --- a/lib/etag.js +++ b/lib/etag.js @@ -6,7 +6,7 @@ const Util = require('util'); const Boom = require('@hapi/boom'); const Bounce = require('@hapi/bounce'); -const LruCache = require('lru-cache'); +const { LRUCache } = require('lru-cache'); const internals = { @@ -109,4 +109,4 @@ exports.apply = async function (response, stat) { }; -exports.Cache = LruCache; +exports.Cache = LRUCache; diff --git a/lib/fs.js b/lib/fs.js index 94f0435..2ed9c65 100755 --- a/lib/fs.js +++ b/lib/fs.js @@ -1,7 +1,6 @@ 'use strict'; -const Fs = require('fs'); -const Util = require('util'); +const Fs = require('fs/promises'); const Boom = require('@hapi/boom'); const Bounce = require('@hapi/bounce'); @@ -9,27 +8,25 @@ const Hoek = require('@hapi/hoek'); const internals = { - methods: { - promised: ['open', 'close', 'fstat', 'readdir'], - raw: ['createReadStream'] - }, + methods: ['open', 'readdir'], notFound: new Set(['ENOENT', 'ENOTDIR']) }; + exports.File = class { constructor(path) { this.path = path; - this.fd = null; + this.handle = null; } async open(mode) { - Hoek.assert(this.fd === null); + Hoek.assert(this.handle === null); try { - this.fd = await exports.open(this.path, mode); + this.handle = await exports.open(this.path, mode); } catch (err) { const data = { path: this.path }; @@ -49,18 +46,18 @@ exports.File = class { close() { - if (this.fd !== null) { - Bounce.background(exports.close(this.fd)); - this.fd = null; + if (this.handle !== null) { + Bounce.background(this.handle.close()); + this.handle = null; } } async stat() { - Hoek.assert(this.fd !== null); + Hoek.assert(this.handle !== null); try { - const stat = await exports.fstat(this.fd); + const stat = await this.handle.stat(); if (stat.isDirectory()) { throw Boom.forbidden(null, { code: 'EISDIR', path: this.path }); @@ -69,7 +66,7 @@ exports.File = class { return stat; } catch (err) { - this.close(this.fd); + this.close(); Bounce.rethrow(err, ['boom', 'system']); throw Boom.boomify(err, { message: 'Failed to stat file', data: { path: this.path } }); @@ -84,26 +81,14 @@ exports.File = class { createReadStream(options) { - Hoek.assert(this.fd !== null); - - options = Object.assign({ fd: this.fd, start: 0 }, options); + Hoek.assert(this.handle !== null); - const stream = exports.createReadStream(this.path, options); - - if (options.autoClose !== false) { - this.fd = null; // The stream now owns the fd - } - - return stream; + return this.handle.createReadStream({ start: 0, ...options }); } }; -// Export Fs methods +// Export Fs methods to allow overriding -for (const method of internals.methods.raw) { +for (const method of internals.methods) { exports[method] = Fs[method].bind(Fs); } - -for (const method of internals.methods.promised) { - exports[method] = Util.promisify(Fs[method]); -} diff --git a/package.json b/package.json index 9c7529f..f15c37c 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "@hapi/bounce": "^3.0.1", "@hapi/hoek": "^11.0.2", "@hapi/validate": "^2.0.1", - "lru-cache": "^7.14.1" + "lru-cache": "^11.2.2" }, "devDependencies": { "@hapi/code": "^9.0.3", @@ -34,9 +34,9 @@ "@hapi/file": "^3.0.0", "@hapi/hapi": "^21.3.0", "@hapi/lab": "^26.0.0", - "@types/node": "^14.18.37", - "joi": "^17.8.3", - "typescript": "^4.9.5" + "@types/node": "^20.19.24", + "joi": "^18.0.1", + "typescript": "~5.9.3" }, "scripts": { "test": "lab -f -a @hapi/code -t 100 -L -Y", diff --git a/test/directory.js b/test/directory.js index 1963fc1..56b3030 100755 --- a/test/directory.js +++ b/test/directory.js @@ -797,19 +797,28 @@ describe('directory', () => { it('only stats the file system once when requesting a file', async () => { - const orig = InertFs.fstat; let callCnt = 0; - InertFs.fstat = function (...args) { - callCnt++; - return orig.apply(InertFs, args); + const origOpen = InertFs.open; + InertFs.open = async function (...openArgs) { + + const handle = await origOpen.call(this, ...openArgs); + const origStat = handle.stat; + + handle.stat = function (...args) { + + callCnt++; + return origStat.call(this, ...args); + }; + + return handle; }; const server = await provisionServer(); server.route({ method: 'GET', path: '/directory/{path*}', handler: { directory: { path: './' } } }); const res = await server.inject('/directory/directory.js'); - Fs.fstat = orig; + InertFs.open = origOpen; expect(callCnt).to.equal(1); expect(res.statusCode).to.equal(200); expect(res.payload).to.contain('hapi'); diff --git a/test/esm.js b/test/esm.js index 5c8bc8f..23740e4 100644 --- a/test/esm.js +++ b/test/esm.js @@ -19,7 +19,7 @@ describe('import()', () => { it('exposes all methods and classes as named imports', () => { - expect(Object.keys(Inert)).to.equal([ + expect(Object.keys(Inert).filter((k) => k !== 'module.exports')).to.equal([ 'default', 'plugin' ]); diff --git a/test/file.js b/test/file.js index e695acc..309ed7f 100755 --- a/test/file.js +++ b/test/file.js @@ -25,6 +25,31 @@ const { describe, it } = lab; const expect = Code.expect; +internals.mockNextInertFs = function (method, handler) { + + const origOpen = InertFs.open; + InertFs.open = async function (...openArgs) { + + InertFs.open = origOpen; + + if (method === 'open') { + return handler.call(this, origOpen.bind(this), ...openArgs); + } + + const handle = await origOpen.call(this, ...openArgs); + + const origMethod = handle[method]; + handle[method] = function (...args) { + + handle[method] = origMethod; + return handler.call(this, origMethod.bind(this), ...args); + }; + + return handle; + }; +}; + + describe('file', () => { describe('handler()', () => { @@ -303,7 +328,7 @@ describe('file', () => { const res = await server.inject('/filefn/index.js'); expect(res.statusCode).to.equal(200); expect(res.payload).to.contain('Set correct confine value'); - expect(res.headers['content-type']).to.equal('application/javascript; charset=utf-8'); + expect(res.headers['content-type']).to.equal('text/javascript; charset=utf-8'); expect(res.headers['content-length']).to.exist(); }); @@ -617,20 +642,14 @@ describe('file', () => { const filepath = Path.join(__dirname, '..', 'package.json'); server.route({ method: 'GET', path: '/file', handler: { file: filepath } }); - // Prepare complicated mocking setup to fake an io error + // Prepare mocking setup to fake an io error - const orig = InertFs.createReadStream; - InertFs.createReadStream = function (path, options) { + internals.mockNextInertFs('createReadStream', function (orig, options) { - InertFs.createReadStream = orig; + process.nextTick(Fs.closeSync, this.fd); - process.nextTick(() => { - - Fs.closeSync(options.fd); - }); - - return InertFs.createReadStream(path, options); - }; + return orig(options); + }); const res = await server.inject('/file'); expect(res.statusCode).to.equal(500); @@ -643,20 +662,14 @@ describe('file', () => { const server = await provisionServer(); server.route({ method: 'GET', path: '/file', handler: { file: Path.join(__dirname, '..', 'package.json') } }); - // Prepare complicated mocking setup to fake an io error + // Prepare mocking setup to fake an io error - const orig = InertFs.createReadStream; - InertFs.createReadStream = function (path, options) { + internals.mockNextInertFs('createReadStream', function (orig, options) { - InertFs.createReadStream = orig; + process.nextTick(Fs.closeSync, this.fd); - process.nextTick(() => { - - Fs.closeSync(options.fd); - }); - - return InertFs.createReadStream(path, options); - }; + return orig(options); + }); const first = server.inject('/file'); const second = server.inject('/file'); @@ -1070,13 +1083,10 @@ describe('file', () => { const filename = File.uniqueFilename(Os.tmpdir()) + '.package.json'; Fs.writeFileSync(filename, 'data'); - const orig = InertFs.fstat; - InertFs.fstat = function (fd) { // can return EIO error + internals.mockNextInertFs('stat', (orig) => { // can return EIO error - InertFs.fstat = orig; throw new Error('failed'); - }; - + }); const server = await provisionServer(); server.route({ method: 'GET', path: '/', handler: { file: { path: filename, confine: false } } }); @@ -1092,12 +1102,10 @@ describe('file', () => { const filename = File.uniqueFilename(Os.tmpdir()) + '.package.json'; Fs.writeFileSync(filename, 'data'); - const orig = InertFs.open; - InertFs.open = function () { // can return EMFILE error + internals.mockNextInertFs('open', () => { // can return EMFILE error - InertFs.open = orig; throw new Error('failed'); - }; + }); const server = await provisionServer(); server.route({ method: 'GET', path: '/', handler: { file: { path: filename, confine: false } } }); @@ -1130,14 +1138,12 @@ describe('file', () => { let didOpen = false; const res1 = await server.inject('/'); - const orig = InertFs.open; - InertFs.open = async function (path, mode) { // fake alternate permission error + internals.mockNextInertFs('open', async (orig, path, mode) => { // fake alternate permission error - InertFs.open = orig; didOpen = true; try { - return await InertFs.open(path, mode); + return await orig(path, mode); } catch (err) { if (err.code === 'EACCES') { @@ -1151,7 +1157,7 @@ describe('file', () => { throw err; } - }; + }); const res2 = await server.inject('/'); @@ -1316,14 +1322,11 @@ describe('file', () => { // Catch createReadStream options let createOptions; - const orig = InertFs.createReadStream; - InertFs.createReadStream = function (path, options) { + internals.mockNextInertFs('createReadStream', (orig, options) => { - InertFs.createReadStream = orig; createOptions = options; - - return InertFs.createReadStream(path, options); - }; + return orig(options); + }); const res = await server.inject({ url: '/file', headers: { 'range': 'bytes=1-4', 'accept-encoding': 'gzip' } }); expect(res.statusCode).to.equal(206);