From 07530310a5d1b1a24a5ff5e1c70cf433e13efa9a Mon Sep 17 00:00:00 2001 From: Dmytro Popov Date: Sun, 29 Mar 2026 21:32:28 +0300 Subject: [PATCH 1/3] Add solution --- .github/workflows/test.yml-template | 23 ++++ package-lock.json | 9 +- package.json | 2 +- src/createServer.js | 178 +++++++++++++++++++++++++++- 4 files changed, 205 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/test.yml-template 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..3650660 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "devDependencies": { "@faker-js/faker": "^8.4.1", "@mate-academy/eslint-config": "latest", - "@mate-academy/scripts": "^1.8.6", + "@mate-academy/scripts": "^2.1.3", "axios": "^1.7.2", "eslint": "^8.57.0", "eslint-plugin-jest": "^28.6.0", @@ -1487,10 +1487,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.3", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-2.1.3.tgz", + "integrity": "sha512-a07wHTj/1QUK2Aac5zHad+sGw4rIvcNl5lJmJpAD7OxeSbnCdyI6RXUHwXhjF5MaVo9YHrJ0xVahyERS2IIyBQ==", "dev": true, + "license": "MIT", "dependencies": { "@octokit/rest": "^17.11.2", "@types/get-port": "^4.2.0", diff --git a/package.json b/package.json index 1d03d64..8e6392d 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.3", "axios": "^1.7.2", "eslint": "^8.57.0", "eslint-plugin-jest": "^28.6.0", diff --git a/src/createServer.js b/src/createServer.js index 1cf1dda..39dcc49 100644 --- a/src/createServer.js +++ b/src/createServer.js @@ -1,8 +1,182 @@ 'use strict'; +const http = require('http'); +const { Readable } = require('stream'); +const zlib = require('zlib'); + +const HTML_PAGE = ` + + + + + Compression App + + +
+ + + +
+ +`; + +const compressionStreams = { + gzip: () => zlib.createGzip(), + deflate: () => zlib.createDeflate(), + br: () => zlib.createBrotliCompress(), +}; + +function sendTextResponse(res, statusCode, message) { + res.writeHead(statusCode, { + 'Content-Type': 'text/plain; charset=utf-8', + }); + res.end(message); +} + +function getBoundary(contentType = '') { + const match = contentType.match(/boundary=(?:"([^"]+)"|([^;]+))/i); + + return match ? match[1] || match[2] : null; +} + +function collectRequestBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +function parseMultipartFormData(bodyBuffer, boundary) { + const normalizedBody = bodyBuffer.toString('latin1'); + const parts = normalizedBody + .split(`--${boundary}`) + .slice(1, -1) + .map((part) => part.replace(/^\r\n|\r\n$/g, '')); + const parsedData = {}; + + for (const part of parts) { + const [rawHeaders, rawContent] = part.split('\r\n\r\n'); + + if (!rawHeaders || rawContent === undefined) { + continue; + } + + const nameMatch = rawHeaders.match(/name="([^"]+)"/i); + + if (!nameMatch) { + continue; + } + + const fieldName = nameMatch[1]; + const content = rawContent.replace(/\r\n$/g, ''); + const filenameMatch = rawHeaders.match(/filename="([^"]*)"/i); + + if (filenameMatch) { + parsedData[fieldName] = { + filename: filenameMatch[1], + content: Buffer.from(content, 'latin1'), + }; + } else { + parsedData[fieldName] = content; + } + } + + return parsedData; +} + +async function handleCompressRequest(req, res) { + const boundary = getBoundary(req.headers['content-type']); + + if (!boundary) { + sendTextResponse(res, 400, 'Invalid form data'); + + return; + } + + const body = await collectRequestBody(req); + const formData = parseMultipartFormData(body, boundary); + const file = formData.file; + const compressionType = formData.compressionType; + + if (!file || !file.filename || !Buffer.isBuffer(file.content) || !compressionType) { + sendTextResponse(res, 400, 'Invalid form data'); + + return; + } + + const createCompressionStream = compressionStreams[compressionType]; + + if (!createCompressionStream) { + sendTextResponse(res, 400, 'Unsupported compression type'); + + return; + } + + const sourceStream = Readable.from(file.content); + const compressionStream = createCompressionStream(); + + res.writeHead(200, { + 'Content-Type': 'application/octet-stream', + 'Content-Disposition': `attachment; filename=${file.filename}.${compressionType}`, + }); + + sourceStream.on('error', () => { + if (!res.headersSent) { + sendTextResponse(res, 500, 'Failed to read file'); + } else { + res.destroy(); + } + }); + + compressionStream.on('error', () => { + if (!res.headersSent) { + sendTextResponse(res, 500, 'Compression failed'); + } else { + res.destroy(); + } + }); + + sourceStream.pipe(compressionStream).pipe(res); +} + function createServer() { - /* Write your code here */ - // Return instance of http.Server class + return http.createServer((req, res) => { + if (req.method === 'GET' && req.url === '/') { + res.writeHead(200, { + 'Content-Type': 'text/html; charset=utf-8', + }); + res.end(HTML_PAGE); + + return; + } + + if (req.url === '/compress' && req.method === 'GET') { + sendTextResponse(res, 400, 'GET method is not supported for /compress'); + + return; + } + + if (req.url === '/compress' && req.method === 'POST') { + handleCompressRequest(req, res).catch(() => { + if (!res.headersSent) { + sendTextResponse(res, 400, 'Invalid form data'); + } else { + res.destroy(); + } + }); + + return; + } + + sendTextResponse(res, 404, 'Not found'); + }); } module.exports = { From 6e2fef7c8ea30ddcc0c55d219051b8b82163d3a7 Mon Sep 17 00:00:00 2001 From: Dmytro Popov Date: Sun, 29 Mar 2026 21:39:53 +0300 Subject: [PATCH 2/3] Add second solution --- src/createServer.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/createServer.js b/src/createServer.js index 39dcc49..51d1b69 100644 --- a/src/createServer.js +++ b/src/createServer.js @@ -105,7 +105,12 @@ async function handleCompressRequest(req, res) { const file = formData.file; const compressionType = formData.compressionType; - if (!file || !file.filename || !Buffer.isBuffer(file.content) || !compressionType) { + if ( + !file || + !file.filename || + !Buffer.isBuffer(file.content) || + !compressionType + ) { sendTextResponse(res, 400, 'Invalid form data'); return; From c73c9bbca6f9d64546edac9fe9d14b08d175e53b Mon Sep 17 00:00:00 2001 From: Dmytro Popov Date: Mon, 30 Mar 2026 09:13:46 +0300 Subject: [PATCH 3/3] Add third solution --- readme.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/readme.md b/readme.md index e3ae78f..b10c924 100644 --- a/readme.md +++ b/readme.md @@ -4,10 +4,10 @@ Implement a page with HTML `form` that allows to: - choose a compression type supported by zlib (add select field with name `compressionType` and options: `gzip`, `deflate`, `br`) - add a button to submit the form - send submitted form data to the server via POST request to `/compress` endpoint -- and receive a compressed file in response with the same name as the original file but with appended compression type extension (`.gz`, `.dfl`, `.br` respectively), example: +- and receive a compressed file in response with the same name as the original file but with appended compression type extension (`.gzip`, `.deflate`, `.br` respectively), example: - original file: `file.txt` - compression type: `gzip` - - compressed file: `file.txt.gz` + - compressed file: `file.txt.gzip` To pass the task you also need to implement a server that: - use Streams