From dbaa48812a25a42e1a834b13883ebaba607cec61 Mon Sep 17 00:00:00 2001 From: Oleksandr Date: Sat, 6 Dec 2025 01:37:12 +0200 Subject: [PATCH 1/4] solution --- src/createServer.js | 151 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 149 insertions(+), 2 deletions(-) diff --git a/src/createServer.js b/src/createServer.js index 1cf1dda..a8454ad 100644 --- a/src/createServer.js +++ b/src/createServer.js @@ -1,10 +1,157 @@ 'use strict'; +const http = require('http'); +const fs = require('fs'); +const path = require('path'); +const zlib = require('zlib'); +const busboy = require('busboy'); + function createServer() { - /* Write your code here */ - // Return instance of http.Server class + const server = new http.Server(); + + server.on('request', (req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`); + + if (req.method === 'POST' && url.pathname === '/compress') { + const bb = busboy({ headers: req.headers }); + + let compressionType = null; + // Зберігаємо інформацію про файл та функцію для його обробки + let fileInfo = null; + + // 1. Обробник 'file': ПРИЗУПИНЯЄМО потік і ЗБЕРІГАЄМО логіку + bb.on('file', (fieldname, fileStream, filename) => { + // Запобігаємо обробці більше одного файлу + if (fileInfo) { + fileStream.resume(); + + return; + } + + // ПРИЗУПИНЯЄМО потік негайно, щоб не втратити дані + fileStream.pause(); + + // Функція, яка запустить компресію після отримання compressionType + const processor = () => { + let compressor; + + if (compressionType === 'gzip') { + compressor = zlib.createGzip(); + } else if (compressionType === 'deflate') { + compressor = zlib.createDeflate(); + } else if (compressionType === 'br') { + compressor = zlib.createBrotliCompress(); + } else { + res.statusCode = 400; + + return res.end('Unknown compression type'); + } + + // ВИПРАВЛЕННЯ: Використовуємо повне ім'я, як вимагають тести. + const fileExt = compressionType; + + res.setHeader( + 'Content-Disposition', + `attachment; filename=${filename}.${fileExt}`, + ); + + // ВІДНОВЛЮЄМО потік і підключаємо його + fileStream.pipe(compressor).pipe(res); + }; + + // Зберігаємо всі дані разом + fileInfo = { fileStream, filename, processor }; + + // Якщо compressionType вже був встановлений + if (compressionType !== null) { + fileInfo.processor(); + } + }); + + // 2. Обробник 'field': Отримуємо тип стиснення та ЗАПУСКАЄМО обробку + bb.on('field', (name, val) => { + if (name === 'compressionType') { + compressionType = val; + + // Якщо файл вже був отриманий та призупинений + if (fileInfo) { + fileInfo.processor(); + } + } + }); + + // 3. Обробник 'finish': Перевірка на таймаут/зависання + bb.on('finish', () => { + // Якщо не було файлу + if (!fileInfo) { + res.statusCode = 400; + + return res.end('No file or compression type received'); + } + + if (compressionType === null) { + // Споживаємо потік, щоб не зависнути, і завершуємо з помилкою. + fileInfo.fileStream.resume(); + res.statusCode = 400; + + return res.end('Missing compressionType field'); + } + + // Успішна обробка (потік завершиться через pipe) + }); + + // Обробка помилок Busboy + bb.on('error', (err) => { + res.statusCode = 500; + // ВИПРАВЛЕННЯ: Коректне завершення відповіді повідомленням про помилку + res.end(`Busboy error: ${err.message}`); + }); + + // Запуск парсингу + req.pipe(bb); + + return; + } + + // --- Логіка для GET-запитів --- + if (req.method === 'GET' && url.pathname === '/compress') { + res.statusCode = 400; + + return res.end('Use POST'); + } + + const fileName = url.pathname.slice(1) || 'index.html'; + const filePath = path.resolve('public', fileName); + + if (!fs.existsSync(filePath)) { + res.statusCode = 404; + res.end('file dont found'); + + return; + } + + const ext = path.extname(filePath); + const mimeTypes = { + '.html': 'text/html', + '.css': 'text/css', + '.js': 'application/javascript', + '.json': 'application/json', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + }; + + res.setHeader('Content-Type', mimeTypes[ext] || 'text/plain'); + fs.createReadStream(filePath).pipe(res); + }); + + return server; } +createServer().listen(3006); + module.exports = { createServer, }; From 393aad883bc31b5978d6015527d49877d3fab228 Mon Sep 17 00:00:00 2001 From: Oleksandr Date: Sat, 6 Dec 2025 01:38:10 +0200 Subject: [PATCH 2/4] solution --- .github/workflows/test.yml-template | 23 +++++++++++++ package-lock.json | 31 +++++++++++++++--- package.json | 5 ++- public/index.css | 50 +++++++++++++++++++++++++++++ public/index.html | 24 ++++++++++++++ 5 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/test.yml-template create mode 100644 public/index.css create mode 100644 public/index.html diff --git a/.github/workflows/test.yml-template b/.github/workflows/test.yml-template new file mode 100644 index 0000000..bb13dfc --- /dev/null +++ b/.github/workflows/test.yml-template @@ -0,0 +1,23 @@ +name: Test + +on: + pull_request: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [20.x] + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - run: npm test diff --git a/package-lock.json b/package-lock.json index d0b3b95..e267868 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,13 @@ "version": "1.0.0", "hasInstallScript": true, "license": "GPL-3.0", + "dependencies": { + "busboy": "^1.6.0" + }, "devDependencies": { "@faker-js/faker": "^8.4.1", "@mate-academy/eslint-config": "latest", - "@mate-academy/scripts": "^1.8.6", + "@mate-academy/scripts": "^2.1.2", "axios": "^1.7.2", "eslint": "^8.57.0", "eslint-plugin-jest": "^28.6.0", @@ -1487,10 +1490,11 @@ } }, "node_modules/@mate-academy/scripts": { - "version": "1.8.6", - "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.8.6.tgz", - "integrity": "sha512-b4om/whj4G9emyi84ORE3FRZzCRwRIesr8tJHXa8EvJdOaAPDpzcJ8A0sFfMsWH9NUOVmOwkBtOXDu5eZZ00Ig==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-2.1.2.tgz", + "integrity": "sha512-gUXFdqqOfYzF9R3RSx2pCa5GLdOkxB9bFbF+dpUpzucdgGAANqOGdqpmNnMj+e3xA9YHraUWq3xo9cwe5vD9pQ==", "dev": true, + "license": "MIT", "dependencies": { "@octokit/rest": "^17.11.2", "@types/get-port": "^4.2.0", @@ -2739,6 +2743,17 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/call-bind": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", @@ -8040,6 +8055,14 @@ "node": ">=8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", diff --git a/package.json b/package.json index 1d03d64..bf27fc7 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "devDependencies": { "@faker-js/faker": "^8.4.1", "@mate-academy/eslint-config": "latest", - "@mate-academy/scripts": "^1.8.6", + "@mate-academy/scripts": "^2.1.2", "axios": "^1.7.2", "eslint": "^8.57.0", "eslint-plugin-jest": "^28.6.0", @@ -30,5 +30,8 @@ }, "mateAcademy": { "projectType": "javascript" + }, + "dependencies": { + "busboy": "^1.6.0" } } diff --git a/public/index.css b/public/index.css new file mode 100644 index 0000000..26ce2b3 --- /dev/null +++ b/public/index.css @@ -0,0 +1,50 @@ +body { + font-family: Arial, sans-serif; + background: #f5f5f5; + margin: 0; + padding: 0; + + display: flex; + justify-content: center; + align-items: center; + height: 100vh; +} + +.form { + background: #fff; + padding: 25px 30px; + border-radius: 12px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); + width: 320px; + display: flex; + flex-direction: column; + gap: 15px; +} + +label { + font-weight: bold; + margin-bottom: 5px; +} + +input[type="file"], +select { + padding: 8px; + border: 1px solid #ccc; + border-radius: 6px; + font-size: 14px; +} + +button { + padding: 10px; + background: #007bff; + border: none; + color: white; + font-size: 16px; + border-radius: 6px; + cursor: pointer; + transition: 0.2s; +} + +button:hover { + background: #0056c7; +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..2a5ed54 --- /dev/null +++ b/public/index.html @@ -0,0 +1,24 @@ + + + + + + Document + + + +
+ + + + + + + +
+ + From aa509fff93331d0696009572ef9d34d0ca0e121f Mon Sep 17 00:00:00 2001 From: Oleksandr Date: Sat, 6 Dec 2025 02:01:14 +0200 Subject: [PATCH 3/4] solution --- src/createServer.js | 85 +++++++++++++++++++++++++++------------------ 1 file changed, 52 insertions(+), 33 deletions(-) diff --git a/src/createServer.js b/src/createServer.js index a8454ad..7108e64 100644 --- a/src/createServer.js +++ b/src/createServer.js @@ -6,33 +6,56 @@ const path = require('path'); const zlib = require('zlib'); const busboy = require('busboy'); +// EXTENSIONS EXPECTED BY TESTS +const EXT_MAP = { + gzip: 'gzip', + deflate: 'deflate', + br: 'br', +}; + function createServer() { const server = new http.Server(); server.on('request', (req, res) => { const url = new URL(req.url, `http://${req.headers.host}`); + // --------------------------------------------- + // POST /compress + // --------------------------------------------- if (req.method === 'POST' && url.pathname === '/compress') { const bb = busboy({ headers: req.headers }); let compressionType = null; - // Зберігаємо інформацію про файл та функцію для його обробки let fileInfo = null; + let invalidField = false; + + // FILE HANDLER + bb.on('file', (fieldname, fileStream, info) => { + // field must be named exactly "file" + if (fieldname !== 'file') { + invalidField = true; + fileStream.resume(); + + return; + } - // 1. Обробник 'file': ПРИЗУПИНЯЄМО потік і ЗБЕРІГАЄМО логіку - bb.on('file', (fieldname, fileStream, filename) => { - // Запобігаємо обробці більше одного файлу if (fileInfo) { fileStream.resume(); return; } - // ПРИЗУПИНЯЄМО потік негайно, щоб не втратити дані + const filename = info.filename; + fileStream.pause(); - // Функція, яка запустить компресію після отримання compressionType const processor = () => { + if (!EXT_MAP[compressionType]) { + res.statusCode = 400; + + return res.end('Unknown compression type'); + } + let compressor; if (compressionType === 'gzip') { @@ -41,93 +64,91 @@ function createServer() { compressor = zlib.createDeflate(); } else if (compressionType === 'br') { compressor = zlib.createBrotliCompress(); - } else { - res.statusCode = 400; - - return res.end('Unknown compression type'); } - // ВИПРАВЛЕННЯ: Використовуємо повне ім'я, як вимагають тести. - const fileExt = compressionType; + const fileExt = EXT_MAP[compressionType]; res.setHeader( 'Content-Disposition', `attachment; filename=${filename}.${fileExt}`, ); - // ВІДНОВЛЮЄМО потік і підключаємо його fileStream.pipe(compressor).pipe(res); }; - // Зберігаємо всі дані разом fileInfo = { fileStream, filename, processor }; - // Якщо compressionType вже був встановлений if (compressionType !== null) { - fileInfo.processor(); + processor(); } }); - // 2. Обробник 'field': Отримуємо тип стиснення та ЗАПУСКАЄМО обробку + // FIELDS bb.on('field', (name, val) => { if (name === 'compressionType') { compressionType = val; - // Якщо файл вже був отриманий та призупинений if (fileInfo) { fileInfo.processor(); } } }); - // 3. Обробник 'finish': Перевірка на таймаут/зависання + // FINISH HANDLER bb.on('finish', () => { - // Якщо не було файлу + if (invalidField) { + res.statusCode = 400; + + return res.end('Invalid file field name'); + } + if (!fileInfo) { res.statusCode = 400; - return res.end('No file or compression type received'); + return res.end('No file or compressionType received'); + } + + if (!compressionType) { + return res.writeHead(400).end(); } - if (compressionType === null) { - // Споживаємо потік, щоб не зависнути, і завершуємо з помилкою. + if (!compressionType) { fileInfo.fileStream.resume(); res.statusCode = 400; return res.end('Missing compressionType field'); } - - // Успішна обробка (потік завершиться через pipe) }); - // Обробка помилок Busboy bb.on('error', (err) => { res.statusCode = 500; - // ВИПРАВЛЕННЯ: Коректне завершення відповіді повідомленням про помилку res.end(`Busboy error: ${err.message}`); }); - // Запуск парсингу req.pipe(bb); return; } - // --- Логіка для GET-запитів --- + // --------------------------------------------- + // GET /compress — invalid + // --------------------------------------------- if (req.method === 'GET' && url.pathname === '/compress') { res.statusCode = 400; return res.end('Use POST'); } + // --------------------------------------------- + // STATIC FILES + // --------------------------------------------- const fileName = url.pathname.slice(1) || 'index.html'; const filePath = path.resolve('public', fileName); if (!fs.existsSync(filePath)) { res.statusCode = 404; - res.end('file dont found'); - return; + return res.end('file dont found'); } const ext = path.extname(filePath); @@ -150,8 +171,6 @@ function createServer() { return server; } -createServer().listen(3006); - module.exports = { createServer, }; From 1337cb28b543890bbc0eed032993f069f1a67121 Mon Sep 17 00:00:00 2001 From: Oleksandr Date: Sun, 7 Dec 2025 01:11:54 +0200 Subject: [PATCH 4/4] solution --- src/createServer.js | 157 +++++++++++++++++++++----------------------- 1 file changed, 74 insertions(+), 83 deletions(-) diff --git a/src/createServer.js b/src/createServer.js index 7108e64..c8f9514 100644 --- a/src/createServer.js +++ b/src/createServer.js @@ -19,110 +19,106 @@ function createServer() { server.on('request', (req, res) => { const url = new URL(req.url, `http://${req.headers.host}`); - // --------------------------------------------- - // POST /compress - // --------------------------------------------- if (req.method === 'POST' && url.pathname === '/compress') { const bb = busboy({ headers: req.headers }); let compressionType = null; let fileInfo = null; - let invalidField = false; - // FILE HANDLER - bb.on('file', (fieldname, fileStream, info) => { - // field must be named exactly "file" - if (fieldname !== 'file') { - invalidField = true; - fileStream.resume(); - - return; - } - - if (fileInfo) { - fileStream.resume(); - - return; - } - - const filename = info.filename; - - fileStream.pause(); - - const processor = () => { - if (!EXT_MAP[compressionType]) { - res.statusCode = 400; - - return res.end('Unknown compression type'); - } - - let compressor; - - if (compressionType === 'gzip') { - compressor = zlib.createGzip(); - } else if (compressionType === 'deflate') { - compressor = zlib.createDeflate(); - } else if (compressionType === 'br') { - compressor = zlib.createBrotliCompress(); - } - - const fileExt = EXT_MAP[compressionType]; - - res.setHeader( - 'Content-Disposition', - `attachment; filename=${filename}.${fileExt}`, - ); - - fileStream.pipe(compressor).pipe(res); - }; - - fileInfo = { fileStream, filename, processor }; - - if (compressionType !== null) { - processor(); - } - }); - - // FIELDS bb.on('field', (name, val) => { if (name === 'compressionType') { compressionType = val; - if (fileInfo) { - fileInfo.processor(); + if (fileInfo && !fileInfo.started) { + fileInfo.start(); } } }); - // FINISH HANDLER - bb.on('finish', () => { - if (invalidField) { - res.statusCode = 400; + bb.on('file', (name, file, info) => { + file.pause(); + + fileInfo = { + file, + info, + started: false, + start() { + if (this.started) { + return; + } + this.started = true; + + if (!compressionType) { + return; + } // дочекаємося пізніше + + if (!EXT_MAP[compressionType]) { + res.statusCode = 400; + + return res.end('Invalid compressionType'); + } + + const outName = `${info.filename}.${EXT_MAP[compressionType]}`; + + const compressor = + compressionType === 'gzip' + ? zlib.createGzip() + : compressionType === 'deflate' + ? zlib.createDeflate() + : zlib.createBrotliCompress(); + + res.statusCode = 200; + + res.setHeader( + 'Content-Disposition', + `attachment; filename=${outName}`, + ); + + file.on('error', () => { + if (!res.headersSent) { + res.statusCode = 500; + } + res.end(); + }); + + compressor.on('error', () => { + if (!res.headersSent) { + res.statusCode = 500; + } + res.end(); + }); + + file.resume(); + file.pipe(compressor).pipe(res); + }, + }; - return res.end('Invalid file field name'); + if (compressionType) { + fileInfo.start(); } + }); + bb.on('finish', () => { if (!fileInfo) { res.statusCode = 400; - return res.end('No file or compressionType received'); + return res.end('No file'); } if (!compressionType) { - return res.writeHead(400).end(); - } + // прибираємо пайпи, якщо були + fileInfo.file.unpipe(); - if (!compressionType) { - fileInfo.fileStream.resume(); + // дочитуємо файл до кінця, інакше busboy зависне + fileInfo.file.resume(); res.statusCode = 400; - return res.end('Missing compressionType field'); + return res.end('Missing compressionType'); } - }); - bb.on('error', (err) => { - res.statusCode = 500; - res.end(`Busboy error: ${err.message}`); + if (!fileInfo.started) { + fileInfo.start(); + } }); req.pipe(bb); @@ -130,18 +126,13 @@ function createServer() { return; } - // --------------------------------------------- - // GET /compress — invalid - // --------------------------------------------- if (req.method === 'GET' && url.pathname === '/compress') { res.statusCode = 400; + res.end(); - return res.end('Use POST'); + return; } - // --------------------------------------------- - // STATIC FILES - // --------------------------------------------- const fileName = url.pathname.slice(1) || 'index.html'; const filePath = path.resolve('public', fileName);