From c9fb418bb02ed4b8ad82c5c8b879c76fa8bb6571 Mon Sep 17 00:00:00 2001 From: KaKa Date: Fri, 12 Jul 2024 15:26:34 +0800 Subject: [PATCH 1/3] feat: extends send result to provide ability of custom handling --- README.md | 69 ++++++++++++++++++ lib/send.js | 45 +++++++++--- test/send.3.test.js | 160 ++++++++++++++++++++++++++++++++++++++++++ types/index.d.ts | 28 +++++++- types/index.test-d.ts | 23 +++++- 5 files changed, 312 insertions(+), 13 deletions(-) create mode 100644 test/send.3.test.js diff --git a/README.md b/README.md index acf1d51..f3d090d 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,75 @@ var server = http.createServer(function onRequest (req, res) { server.listen(3000) ``` +### Custom directory index view + +This is an example of serving up a structure of directories with a +custom function to render a listing of a directory. + +```js +var http = require('node:http') +var fs = require('node:fs') +var parseUrl = require('parseurl') +var send = require('@fastify/send') + +// Transfer arbitrary files from within /www/example.com/public/* +// with a custom handler for directory listing +var server = http.createServer(async function onRequest (req, res) { + const { statusCode, headers, stream, type, metadata } = await send(req, parseUrl(req).pathname, { index: false, root: '/www/public' }) + if(type === 'directory') { + // get directory list + const list = await readdir(metadata.path) + // render an index for the directory + res.writeHead(200, { 'Content-Type': 'text/plain; charset=UTF-8' }) + res.end(list.join('\n') + '\n') + } else { + res.writeHead(statusCode, headers) + stream.pipe(res) + } +}) + +server.listen(3000) +``` + +### Serving from a root directory with custom error-handling + +```js +var http = require('node:http') +var parseUrl = require('parseurl') +var send = require('@fastify/send') + +var server = http.createServer(async function onRequest (req, res) { + // transfer arbitrary files from within + // /www/example.com/public/* + const { statusCode, headers, stream, type, metadata } = await send(req, parseUrl(req).pathname, { root: '/www/public' }) + switch (type) { + case 'directory': { + // your custom directory handling logic: + res.writeHead(301, { + 'Location': metadata.requestPath + '/' + }) + res.end('Redirecting to ' + metadata.requestPath + '/') + break + } + case 'error': { + // your custom error-handling logic: + res.writeHead(metadata.error.status ?? 500, {}) + res.end(metadata.error.message) + break + } + default: { + // your custom headers + // serve all files for download + res.setHeader('Content-Disposition', 'attachment') + res.writeHead(statusCode, headers) + stream.pipe(res) + } + } +}) + +server.listen(3000) +``` + ## License [MIT](LICENSE) diff --git a/lib/send.js b/lib/send.js index f98d375..ef123e8 100644 --- a/lib/send.js +++ b/lib/send.js @@ -403,7 +403,10 @@ function sendError (statusCode, err) { return { statusCode, headers, - stream: Readable.from(doc[0]) + stream: Readable.from(doc[0]), + // metadata + type: 'error', + metadata: { error: err } } } @@ -427,7 +430,7 @@ function sendStatError (err) { * @api private */ -function sendNotModified (headers) { +function sendNotModified (headers, path, stat) { debug('not modified') delete headers['Content-Encoding'] @@ -439,7 +442,10 @@ function sendNotModified (headers) { return { statusCode: 304, headers, - stream: Readable.from('') + stream: Readable.from(''), + // metadata + type: 'file', + metadata: { path, stat } } } @@ -498,7 +504,7 @@ function sendFileDirectly (request, path, stat, options) { } if (isNotModifiedFailure(request, headers)) { - return sendNotModified(headers) + return sendNotModified(headers, path, stat) } } @@ -556,7 +562,14 @@ function sendFileDirectly (request, path, stat, options) { // HEAD support if (request.method === 'HEAD') { - return { statusCode, headers, stream: Readable.from('') } + return { + statusCode, + headers, + stream: Readable.from(''), + // metadata + type: 'file', + metadata: { path, stat } + } } const stream = fs.createReadStream(path, { @@ -564,15 +577,22 @@ function sendFileDirectly (request, path, stat, options) { end: Math.max(offset, offset + len - 1) }) - return { statusCode, headers, stream } + return { + statusCode, + headers, + stream, + // metadata + type: 'file', + metadata: { path, stat } + } } -function sendRedirect (path) { - if (hasTrailingSlash(path)) { +function sendRedirect (path, options) { + if (hasTrailingSlash(options.path)) { return sendError(403) } - const loc = encodeURI(collapseLeadingSlashes(path + '/')) + const loc = encodeURI(collapseLeadingSlashes(options.path + '/')) const doc = createHtmlDocument('Redirecting', 'Redirecting to ' + escapeHtml(loc) + '') @@ -586,7 +606,10 @@ function sendRedirect (path) { return { statusCode: 301, headers, - stream: Readable.from(doc[0]) + stream: Readable.from(doc[0]), + // metadata + type: 'directory', + metadata: { requestPath: options.path, path } } } @@ -636,7 +659,7 @@ async function sendFile (request, path, options) { return sendError(404) } if (error) return sendStatError(error) - if (stat.isDirectory()) return sendRedirect(options.path) + if (stat.isDirectory()) return sendRedirect(path, options) return sendFileDirectly(request, path, stat, options) } diff --git a/test/send.3.test.js b/test/send.3.test.js new file mode 100644 index 0000000..ad2b40f --- /dev/null +++ b/test/send.3.test.js @@ -0,0 +1,160 @@ +'use strict' + +const { test } = require('tap') +const http = require('node:http') +const path = require('node:path') +const request = require('supertest') +const { readdir } = require('node:fs/promises') +const send = require('../lib/send').send + +const fixtures = path.join(__dirname, 'fixtures') + +test('send(file)', function (t) { + t.plan(5) + + t.test('file type', function (t) { + t.plan(6) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream, type, metadata } = await send(req, req.url, { root: fixtures }) + t.equal(type, 'file') + t.ok(metadata.path) + t.ok(metadata.stat) + t.notOk(metadata.error) + t.notOk(metadata.requestPath) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/name.txt') + .expect('Content-Length', '4') + .expect(200, 'tobi', err => t.error(err)) + }) + + t.test('directory type', function (t) { + t.plan(6) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream, type, metadata } = await send(req, req.url, { root: fixtures }) + t.equal(type, 'directory') + t.ok(metadata.path) + t.notOk(metadata.stat) + t.notOk(metadata.error) + t.ok(metadata.requestPath) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/pets') + .expect('Location', '/pets/') + .expect(301, err => t.error(err)) + }) + + t.test('error type', function (t) { + t.plan(2) + + t.test('with metadata.error', function (t) { + t.plan(6) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream, type, metadata } = await send(req, req.url, { root: fixtures }) + t.equal(type, 'error') + t.notOk(metadata.path) + t.notOk(metadata.stat) + t.ok(metadata.error) + t.notOk(metadata.requestPath) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + const path = Array(100).join('foobar') + request(app) + .get('/' + path) + .expect(404, err => t.error(err)) + }) + + t.test('without metadata.error', function (t) { + t.plan(6) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream, type, metadata } = await send(req, req.url, { root: fixtures }) + t.equal(type, 'error') + t.notOk(metadata.path) + t.notOk(metadata.stat) + t.notOk(metadata.error) + t.notOk(metadata.requestPath) + res.writeHead(statusCode, headers) + stream.pipe(res) + }) + + request(app) + .get('/some%00thing.txt') + .expect(400, /Bad Request/, err => t.error(err)) + }) + }) + + t.test('custom directory index view', function (t) { + t.plan(1) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream, type, metadata } = await send(req, req.url, { root: fixtures }) + if (type === 'directory') { + const list = await readdir(metadata.path) + res.writeHead(200, { 'Content-Type': 'text/plain; charset=UTF-8' }) + res.end(list.join('\n') + '\n') + } else { + res.writeHead(statusCode, headers) + stream.pipe(res) + } + }) + + request(app) + .get('/pets') + .expect('Content-Type', 'text/plain; charset=UTF-8') + .expect(200, '.hidden\nindex.html\n', err => t.error(err)) + }) + + t.test('serving from a root directory with custom error-handling', function (t) { + t.plan(3) + + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream, type, metadata } = await send(req, req.url, { root: fixtures }) + switch (type) { + case 'directory': { + res.writeHead(301, { + Location: metadata.requestPath + '/' + }) + res.end('Redirecting to ' + metadata.requestPath + '/') + break + } + case 'error': { + res.writeHead(metadata.error.status ?? 500, {}) + res.end(metadata.error.message) + break + } + default: { + // serve all files for download + res.setHeader('Content-Disposition', 'attachment') + res.writeHead(statusCode, headers) + stream.pipe(res) + } + } + }) + + request(app) + .get('/pets') + .expect('Location', '/pets/') + .expect(301, err => t.error(err)) + + request(app) + .get('/not-exists') + .expect(500, err => t.error(err)) + + request(app) + .get('/pets/index.html') + .expect('Content-Disposition', 'attachment') + .expect(200, err => t.error(err)) + }) +}) diff --git a/types/index.d.ts b/types/index.d.ts index 7f93448..c25e111 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -4,6 +4,7 @@ /// +import { Dirent } from "fs"; import * as stream from "stream"; /** @@ -111,12 +112,37 @@ declare namespace send { start?: number | undefined; } - export interface SendResult { + export interface BaseSendResult { statusCode: number headers: Record stream: stream.Readable } + export interface FileSendResult extends BaseSendResult { + type: 'file' + metadata: { + path: string + stat: Dirent + } + } + + export interface DirectorySendResult extends BaseSendResult { + type: 'directory' + metadata: { + path: string + requestPath: string + } + } + + export interface ErrorSendResult extends BaseSendResult { + type: 'error' + metadata: { + error?: Error + } + } + + export type SendResult = FileSendResult | DirectorySendResult | ErrorSendResult + export const send: Send export { send as default } diff --git a/types/index.test-d.ts b/types/index.test-d.ts index 2fc1d4c..62035ea 100644 --- a/types/index.test-d.ts +++ b/types/index.test-d.ts @@ -1,6 +1,7 @@ +import { Dirent } from 'fs'; import { Readable } from 'stream'; import { expectType } from 'tsd'; -import send, { SendResult } from '..'; +import send, { DirectorySendResult, ErrorSendResult, FileSendResult, SendResult } from '..'; send.mime.define({ 'application/x-my-type': ['x-mt', 'x-mtt'] @@ -33,3 +34,23 @@ const req: any = {} expectType(result.stream) } + +const result = await send(req, '/test.html') +switch (result.type) { + case 'file': { + expectType(result) + expectType(result.metadata.path) + expectType(result.metadata.stat) + break + } + case 'directory': { + expectType(result) + expectType(result.metadata.path) + expectType(result.metadata.requestPath) + break + } + case 'error': { + expectType(result) + expectType(result.metadata.error) + } +} \ No newline at end of file From dda0ec0472e1023b93bc5b1825aa75521129d9b8 Mon Sep 17 00:00:00 2001 From: KaKa Date: Fri, 12 Jul 2024 16:07:46 +0800 Subject: [PATCH 2/3] feat: ensure error exists --- lib/createHttpError.js | 23 +++++++++++++++++++ lib/send.js | 3 ++- package.json | 5 +++-- test/send.3.test.js | 51 ++++++++++++------------------------------ types/index.d.ts | 2 +- types/index.test-d.ts | 2 +- 6 files changed, 44 insertions(+), 42 deletions(-) create mode 100644 lib/createHttpError.js diff --git a/lib/createHttpError.js b/lib/createHttpError.js new file mode 100644 index 0000000..ba7bcca --- /dev/null +++ b/lib/createHttpError.js @@ -0,0 +1,23 @@ +'use strict' + +const createError = require('http-errors') + +/** + * Create a HttpError object from simple arguments. + * + * @param {number} status + * @param {Error|object} err + * @private + */ + +function createHttpError (status, err) { + if (!err) { + return createError(status) + } + + return err instanceof Error + ? createError(status, err, { expose: false }) + : createError(status, err) +} + +module.exports.createHttpError = createHttpError diff --git a/lib/send.js b/lib/send.js index ef123e8..226dd6f 100644 --- a/lib/send.js +++ b/lib/send.js @@ -18,6 +18,7 @@ const { isUtf8MimeType } = require('../lib/isUtf8MimeType') const { normalizeList } = require('../lib/normalizeList') const { parseBytesRange } = require('../lib/parseBytesRange') const { parseTokenList } = require('./parseTokenList') +const { createHttpError } = require('./createHttpError') /** * Path function references. @@ -406,7 +407,7 @@ function sendError (statusCode, err) { stream: Readable.from(doc[0]), // metadata type: 'error', - metadata: { error: err } + metadata: { error: createHttpError(statusCode, err) } } } diff --git a/package.json b/package.json index ce9fb38..4e7e356 100644 --- a/package.json +++ b/package.json @@ -22,10 +22,11 @@ "server" ], "dependencies": { + "@lukeed/ms": "^2.0.2", "escape-html": "~1.0.3", "fast-decode-uri-component": "^1.0.1", - "mime": "^3", - "@lukeed/ms": "^2.0.2" + "http-errors": "^2.0.0", + "mime": "^3" }, "devDependencies": { "@fastify/pre-commit": "^2.1.0", diff --git a/test/send.3.test.js b/test/send.3.test.js index ad2b40f..64768c4 100644 --- a/test/send.3.test.js +++ b/test/send.3.test.js @@ -53,46 +53,23 @@ test('send(file)', function (t) { }) t.test('error type', function (t) { - t.plan(2) - - t.test('with metadata.error', function (t) { - t.plan(6) - - const app = http.createServer(async function (req, res) { - const { statusCode, headers, stream, type, metadata } = await send(req, req.url, { root: fixtures }) - t.equal(type, 'error') - t.notOk(metadata.path) - t.notOk(metadata.stat) - t.ok(metadata.error) - t.notOk(metadata.requestPath) - res.writeHead(statusCode, headers) - stream.pipe(res) - }) + t.plan(6) - const path = Array(100).join('foobar') - request(app) - .get('/' + path) - .expect(404, err => t.error(err)) + const app = http.createServer(async function (req, res) { + const { statusCode, headers, stream, type, metadata } = await send(req, req.url, { root: fixtures }) + t.equal(type, 'error') + t.notOk(metadata.path) + t.notOk(metadata.stat) + t.ok(metadata.error) + t.notOk(metadata.requestPath) + res.writeHead(statusCode, headers) + stream.pipe(res) }) - t.test('without metadata.error', function (t) { - t.plan(6) - - const app = http.createServer(async function (req, res) { - const { statusCode, headers, stream, type, metadata } = await send(req, req.url, { root: fixtures }) - t.equal(type, 'error') - t.notOk(metadata.path) - t.notOk(metadata.stat) - t.notOk(metadata.error) - t.notOk(metadata.requestPath) - res.writeHead(statusCode, headers) - stream.pipe(res) - }) - - request(app) - .get('/some%00thing.txt') - .expect(400, /Bad Request/, err => t.error(err)) - }) + const path = Array(100).join('foobar') + request(app) + .get('/' + path) + .expect(404, err => t.error(err)) }) t.test('custom directory index view', function (t) { diff --git a/types/index.d.ts b/types/index.d.ts index c25e111..31cd91d 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -137,7 +137,7 @@ declare namespace send { export interface ErrorSendResult extends BaseSendResult { type: 'error' metadata: { - error?: Error + error: Error } } diff --git a/types/index.test-d.ts b/types/index.test-d.ts index 62035ea..783ceba 100644 --- a/types/index.test-d.ts +++ b/types/index.test-d.ts @@ -51,6 +51,6 @@ switch (result.type) { } case 'error': { expectType(result) - expectType(result.metadata.error) + expectType(result.metadata.error) } } \ No newline at end of file From efbaed1d879ef014b7f00e968ab5d1c4a5c82b53 Mon Sep 17 00:00:00 2001 From: KaKa Date: Fri, 12 Jul 2024 16:36:42 +0800 Subject: [PATCH 3/3] fixup --- test/send.3.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/send.3.test.js b/test/send.3.test.js index 64768c4..8fa0332 100644 --- a/test/send.3.test.js +++ b/test/send.3.test.js @@ -127,7 +127,7 @@ test('send(file)', function (t) { request(app) .get('/not-exists') - .expect(500, err => t.error(err)) + .expect(404, err => t.error(err)) request(app) .get('/pets/index.html')