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/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
diff --git a/src/createServer.js b/src/createServer.js
index 1cf1dda..51d1b69 100644
--- a/src/createServer.js
+++ b/src/createServer.js
@@ -1,8 +1,187 @@
'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 = {