diff --git a/packages/core/src/helpers/server.test.ts b/packages/core/src/helpers/server.test.ts new file mode 100644 index 000000000..9d095a2ec --- /dev/null +++ b/packages/core/src/helpers/server.test.ts @@ -0,0 +1,164 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import type http from 'http'; +import { vol } from 'memfs'; +import nock from 'nock'; + +import { prepareFile, runServer } from './server'; + +// Use mock files. +jest.mock('fs', () => require('memfs').fs); +jest.mock('fs/promises', () => require('memfs').fs.promises); + +const PORT = 3000; + +describe('Server', () => { + describe('prepareFile', () => { + beforeAll(() => { + vol.fromJSON( + { + '/system/sensitive.txt': 'sensitive data', + '/root/index.html': 'Hello World', + '/root/styles.css': 'body { color: red; }', + }, + '/', + ); + }); + + afterAll(() => { + vol.reset(); + }); + + test('Should return the correct file.', async () => { + const file = await prepareFile('/root', '/styles.css'); + expect(file.found).toBe(true); + expect(file.ext).toBe('css'); + expect(file.content).toBe('body { color: red; }'); + }); + + test('Should handle missing files.', async () => { + const file = await prepareFile('/root', '/nonexistent.txt'); + expect(file.found).toBe(false); + expect(file.content).toBe(''); + }); + + test('Should append index.html when path ends with /', async () => { + const file = await prepareFile('/root', '/'); + expect(file.found).toBe(true); + expect(file.ext).toBe('html'); + expect(file.content).toBe('Hello World'); + }); + + test('Should prevent path traversal attacks', async () => { + const file = await prepareFile('/root', '/../system/sensitive.txt'); + expect(file.found).toBe(false); + }); + }); + + describe('runServer', () => { + let server: http.Server; + + beforeAll(() => { + // Allow local server. + nock.enableNetConnect('127.0.0.1'); + + // Add one file. + vol.fromJSON({ + '/root/index.html': 'Hello World', + }); + }); + + afterAll(() => { + vol.reset(); + nock.cleanAll(); + nock.disableNetConnect(); + }); + + afterEach(() => { + if (!server) { + return; + } + + server.close(); + server.closeAllConnections(); + server.closeIdleConnections(); + }); + + test('Should start the server', async () => { + server = runServer({ + port: PORT, + root: '/root', + }); + expect(server).toBeDefined(); + expect(server.listening).toBe(true); + }); + + test('Should handle routes', async () => { + const getHandler = jest.fn((req, res) => { + res.end('Hello World'); + }); + + const routes = { + '/route': { + get: getHandler, + }, + }; + + server = runServer({ + port: PORT, + root: '/root', + routes, + }); + + const response = await fetch(`http://127.0.0.1:${PORT}/route`); + expect(response.ok).toBe(true); + expect(await response.text()).toBe('Hello World'); + expect(getHandler).toHaveBeenCalled(); + }); + + test("Should fallback to files when routes doesn't hit", async () => { + const routes = { + '/route': { + get: jest.fn(), + }, + }; + + server = runServer({ + port: PORT, + root: '/root', + routes, + }); + + const response = await fetch(`http://127.0.0.1:${PORT}/`); + expect(response.ok).toBe(true); + expect(await response.text()).toBe('Hello World'); + }); + + test('Should use middleware', async () => { + const middleware = jest.fn((response) => { + return { + statusCode: 201, + headers: { + 'Content-Type': 'text/plain', + }, + body: `Content was: ${response.body}`, + }; + }); + + server = runServer({ + port: PORT, + root: '/root', + middleware, + }); + + const response = await fetch(`http://127.0.0.1:${PORT}/`); + expect(response.ok).toBe(true); + expect(response.status).toBe(201); + expect(response.headers.get('Content-Type')).toBe('text/plain'); + expect(await response.text()).toBe('Content was: Hello World'); + expect(middleware).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/src/helpers/server.ts b/packages/core/src/helpers/server.ts new file mode 100644 index 000000000..8a897aa99 --- /dev/null +++ b/packages/core/src/helpers/server.ts @@ -0,0 +1,131 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import fs from 'fs'; +import http from 'http'; +import path from 'path'; + +const MIME_TYPES = { + default: 'application/octet-stream', + html: 'text/html; charset=UTF-8', + js: 'application/javascript', + css: 'text/css', + png: 'image/png', + jpg: 'image/jpg', + gif: 'image/gif', + ico: 'image/x-icon', + svg: 'image/svg+xml', +} as const; + +type File = { + found: boolean; + ext: keyof typeof MIME_TYPES; + content: string; +}; +type RouteVerb = 'get' | 'post' | 'put' | 'patch' | 'delete'; +type Routes = Record< + string, + Partial< + Record< + RouteVerb, + (req: http.IncomingMessage, res: http.ServerResponse) => void | Promise + > + > +>; +type Response = { + statusCode: number; + headers: Record; + body: string; + error?: Error; +}; +type RunServerOptions = { + port: number; + root: string; + routes?: Routes; + middleware?: ( + resp: Response, + req: http.IncomingMessage, + res: http.ServerResponse, + ) => Partial | Promise>; +}; + +// Promise to boolean. +const toBool = [() => true, () => false]; + +export const prepareFile = async (root: string, requestUrl: string): Promise => { + const staticPath = root + ? path.isAbsolute(root) + ? root + : path.resolve(process.cwd(), root) + : process.cwd(); + const url = new URL(requestUrl, 'http://127.0.0.1'); + const paths = [staticPath, url.pathname]; + + if (url.pathname.endsWith('/')) { + paths.push('index.html'); + } + + const filePath = path.join(...paths); + const pathTraversal = !filePath.startsWith(staticPath); + const exists = await fs.promises.access(filePath).then(...toBool); + const found = !pathTraversal && exists; + const ext = path.extname(filePath).substring(1).toLowerCase() as File['ext']; + const fileContent = found ? await fs.promises.readFile(filePath, { encoding: 'utf-8' }) : ''; + + return { found, ext, content: fileContent }; +}; + +export const runServer = ({ port, root, middleware, routes }: RunServerOptions) => { + const server = http.createServer(async (req, res) => { + const response: Response = { + statusCode: 200, + headers: {}, + body: '', + }; + + try { + // Handle routes. + const route = routes?.[req.url || '/']; + if (route) { + const verb = req.method?.toLowerCase() as RouteVerb; + const handler = route[verb]; + if (handler) { + // Hands off to the route handler. + await handler(req, res); + return; + } + } + + // Fallback to files. + const file = await prepareFile(root, req.url || '/'); + const statusCode = file.found ? 200 : 404; + const mimeType = MIME_TYPES[file.ext] || MIME_TYPES.default; + + response.statusCode = statusCode; + response.headers['Content-Type'] = mimeType; + response.body = file.content; + } catch (e: any) { + response.statusCode = 500; + response.headers['Content-Type'] = MIME_TYPES.html; + response.body = 'Internal Server Error'; + response.error = e; + } + + if (middleware) { + const middlewareResponse = await middleware(response, req, res); + response.statusCode = middlewareResponse.statusCode ?? response.statusCode; + response.headers = { + ...response.headers, + ...(middlewareResponse.headers ?? {}), + }; + response.body = middlewareResponse.body ?? response.body; + } + + res.writeHead(response.statusCode, response.headers); + res.end(response.body); + }); + + server.listen(port); + return server; +}; diff --git a/packages/tools/src/commands/dev-server/index.ts b/packages/tools/src/commands/dev-server/index.ts index 7c29194cf..b5dc9ada2 100644 --- a/packages/tools/src/commands/dev-server/index.ts +++ b/packages/tools/src/commands/dev-server/index.ts @@ -3,25 +3,12 @@ // Copyright 2019-Present Datadog, Inc. import { FULL_NAME_BUNDLERS } from '@dd/core/constants'; +import { runServer } from '@dd/core/helpers/server'; import { ROOT } from '@dd/tools/constants'; import chalk from 'chalk'; import { Command, Option } from 'clipanion'; -import fs from 'fs'; -import http from 'http'; +import type http from 'http'; import template from 'lodash.template'; -import path from 'path'; - -const MIME_TYPES = { - default: 'application/octet-stream', - html: 'text/html; charset=UTF-8', - js: 'application/javascript', - css: 'text/css', - png: 'image/png', - jpg: 'image/jpg', - gif: 'image/gif', - ico: 'image/x-icon', - svg: 'image/svg+xml', -} as const; // Some context to use for templating content with {{something}}. const CONTEXT: Record = { @@ -31,15 +18,6 @@ const CONTEXT: Record = { // Templating regex. const INTERPOLATE_RX = /{{([\s\S]+?)}}/g; -// Promise to boolean. -const toBool = [() => true, () => false]; - -type File = { - found: boolean; - ext: keyof typeof MIME_TYPES; - content: string; -}; - class DevServer extends Command { static paths = [['dev-server']]; @@ -115,57 +93,39 @@ class DevServer extends Command { return fileContext; } - async prepareFile(requestUrl: string, context: Record): Promise { - const staticPath = this.root - ? path.isAbsolute(this.root) - ? this.root - : path.resolve(ROOT, this.root) - : ROOT; - const url = new URL(requestUrl, 'http://127.0.0.1'); - const paths = [staticPath, url.pathname]; - - if (url.pathname.endsWith('/')) { - paths.push('index.html'); - } - - const filePath = path.join(...paths); - const pathTraversal = !filePath.startsWith(staticPath); - const exists = await fs.promises.access(filePath).then(...toBool); - const found = !pathTraversal && exists; - const finalPath = found ? filePath : `${staticPath}/404.html`; - const ext = path.extname(finalPath).substring(1).toLowerCase() as File['ext']; - const fileContent = template(await fs.promises.readFile(finalPath, { encoding: 'utf-8' }), { - interpolate: INTERPOLATE_RX, - })(context); - - return { found, ext, content: fileContent }; - } - async execute() { - http.createServer(async (req, res) => { - try { + runServer({ + port: +this.port, + root: this.root, + middleware: async (resp, req) => { + const statusCode = resp.statusCode; const context = this.getContext(req); - const file = await this.prepareFile(req.url || '/', context); - const statusCode = file.found ? 200 : 404; - const mimeType = MIME_TYPES[file.ext] || MIME_TYPES.default; - const c = statusCode === 200 ? chalk.green : chalk.yellow.bold; - - res.writeHead(statusCode, { + const content = template(resp.body, { + interpolate: INTERPOLATE_RX, + })(context); + const headers = { 'Set-Cookie': `context_cookie=${encodeURIComponent(JSON.stringify(context))};SameSite=Strict;`, - 'Content-Type': mimeType, - }); + }; - res.end(file.content); + const c = + { + 200: chalk.green, + 404: chalk.yellow.bold, + 500: chalk.red.bold, + }[statusCode] || chalk.white; console.log(` -> [${c(statusCode.toString())}] ${req.method} ${req.url}`); - } catch (e: any) { - res.writeHead(500, { 'Content-Type': MIME_TYPES.html }); - res.end('Internal Server Error'); - const c = chalk.red.bold; - console.log(` -> [${c('500')}] ${req.method} ${req.url}: ${e.message}`); - console.log(e); - } - }).listen(this.port); + if (resp.error) { + console.log(resp.error); + } + + return { + statusCode: resp.statusCode, + headers, + body: content, + }; + }, + }); console.log(`Server running at http://127.0.0.1:${this.port}/`); } }