Skip to content

Commit

Permalink
Minimal node http server
Browse files Browse the repository at this point in the history
  • Loading branch information
platypii committed Jun 6, 2024
1 parent 7a21d3d commit 541c746
Show file tree
Hide file tree
Showing 5 changed files with 184 additions and 1 deletion.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
},
"scripts": {
"lint": "eslint .",
"serve": "node src/cli.js serve",
"test": "vitest run"
},
"devDependencies": {
Expand Down
27 changes: 27 additions & 0 deletions public/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>hyperparam</title>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Mulish:wght@400;600&display=swap"/>
</head>
<body>
<div id="dropzone">
<div id="overlay">
Drop .parquet file
</div>
<nav>
<h1>hyperparam</h1>
</nav>
<div id="content">
<div id="welcome">
Drop .parquet file here
</div>
</div>
</div>
<input id="file-input" type="file">

<script type="module" src="bundle.min.js"></script>
</body>
</html>
6 changes: 6 additions & 0 deletions public/styles.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
* {
box-sizing: border-box;
font-family: 'Mulish', 'Helvetica Neue', Helvetica, Arial, sans-serif;
margin: 0;
padding: 0;
}
6 changes: 5 additions & 1 deletion src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

if (process.argv[2] === 'chat') {
import('./chat.js').then(({ chat }) => chat())
} else if (process.argv[2] === 'serve') {
import('./serve.js').then(({ serve }) => serve())
} else {
console.log('usage: hyperparam chat')
console.log('usage:')
console.log('hyperparam chat')
console.log('hyperparam serve')
}
145 changes: 145 additions & 0 deletions src/serve.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* Dependency-free http server for serving static files
*/

import http from 'http'
import fs from 'fs/promises'
import path from 'path'
import url from 'url'
import zlib from 'zlib'

const serveDirectory = 'public'

/** @type {Object<string, string>} */
const mimeTypes = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.map': 'application/json',
'.ico': 'image/x-icon',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.txt': 'text/plain',
'.ttf': 'font/ttf',
'.woff2': 'font/woff2',
}

/**
* @typedef {T | Promise<T>} Awaitable<T>
* @template T
*/

/**
* Route an http request
* @typedef {{ status: number, content: string | Buffer, contentType?: string }} ServeResult
* @param {http.IncomingMessage} req
* @returns {Awaitable<ServeResult>}
*/
function handleRequest(req) {
if (!req.url) return { status: 400, content: 'bad request' }
const parsedUrl = url.parse(req.url, true)
const pathname = parsedUrl.pathname || ''

if (pathname.endsWith('/index.html')) {
// redirect index.html to /
return { status: 301, content: pathname.slice(0, -10) }
} else if (pathname.endsWith('/')) {
// serve index.html
return handleStatic(`${pathname}index.html`)
} else {
// serve static files
return handleStatic(pathname)
}
}

/**
* Serve static file from the serve directory
* @param {string} pathname
* @returns {Promise<ServeResult>}
*/
async function handleStatic(pathname) {
const filePath = path.join(process.cwd(), serveDirectory, pathname)
const stats = await fs.stat(filePath).catch(() => undefined)
if (!stats || !stats.isFile()) {
return { status: 404, content: 'not found' }
}

// detect content type
const extname = path.extname(filePath)
if (!mimeTypes[extname]) {
console.error(`serving unknown mimetype ${extname}`)
}
const contentType = mimeTypes[extname] || 'application/octet-stream'

const content = await fs.readFile(filePath)
return { status: 200, content, contentType }
}

/**
* Start http server on given port
* @param {number} port
*/
export function serve(port = 2048) {
// create http server
http.createServer(async (req, res) => {
const startTime = new Date()

// handle request
/** @type {ServeResult} */
let result = { status: 500, content: 'internal server error' }
try {
result = await handleRequest(req)
} catch (err) {
console.error('error handling request', err)
}
let { status, content, contentType } = result

// write http header
/** @type {http.OutgoingHttpHeaders} */
const headers = { 'Connection': 'keep-alive' }
if (contentType) headers['Content-Type'] = contentType
if (status === 301 && typeof content === 'string') {
// handle redirect
headers['Location'] = content
content = ''
}
// compress content
const gzipped = gzip(req, content)
if (gzipped) {
headers['Content-Encoding'] = 'gzip'
content = gzipped
}
res.writeHead(status, headers)
// write http response
res.end(content)

// log request
const endTime = new Date()
const ms = endTime.getTime() - startTime.getTime()
const line = `${endTime.toISOString()} ${status} ${req.method} ${req.url} ${content.length} ${ms}ms`
if (status < 400) {
console.log(line)
} else {
// highlight errors red
console.log(`\x1b[31m${line}\x1b[0m`)
}
}).listen(port, () => {
console.log(`hyperparam server running on http://localhost:${port}`)
})
}

/**
* If the request accepts gzip, compress the content, else undefined
* @param {http.IncomingMessage} req
* @param {string | Buffer} content
* @returns {Buffer | undefined}
*/
function gzip(req, content) {
if (!content) return undefined
const acceptEncoding = req.headers['accept-encoding']
if (acceptEncoding?.includes('gzip')) {
return zlib.gzipSync(content)
}
}

0 comments on commit 541c746

Please sign in to comment.