@node-scarlet/http
Version:
Build APIs Instantly 🏋️♂️
213 lines (206 loc) • 5.61 kB
text/typescript
import * as http from 'http';
import * as qs from 'querystring';
import * as UrlPattern from 'url-pattern';
import { promisify } from 'util';
import { Readable } from 'stream';
export const server = () => new HttpServer();
export const response = (options:any={}) => new HttpResponse(options);
class HttpServer {
private _server:http.Server;
listen;
close;
port;
middlewares;
route;
constructor() {
this.listen = listen.bind(this)
this.close = close.bind(this)
this.port = port.bind(this)
this.route = route.bind(this);
this.middlewares = {
GET: [],
PUT: [],
POST: [],
PATCH: [],
DELETE: [],
}
}
}
/**
* Create an Http Server and start listening for requests
* Bound as a method to `HttpServer`
*/
function listen(port:number=0) {
this._server = http.createServer(async (req:http.IncomingMessage, res:http.ServerResponse) => {
try {
// Separate data
const { url, method, headers } = req;
const [path, querystring] = url.split('?');
let body:any = await asyncBody(req);
// Parse Querystring
const query = Object.assign({}, qs.decode(querystring));
// Try to parse JSON data
if (headers['content-type'] == 'application/json') {
try { body = JSON.parse(body) }
catch (e) {}
}
// Try to parse URL-Encoded data
else if (headers['content-type'] == 'application/x-www-form-urlencoded') {
body = Object.assign({}, qs.decode(body));
}
// Adapt request
const meta = {};
const request:any = {
url,
method,
query,
headers,
body,
}
// See if any middleware matches the incoming request
for (const m of this.middlewares[method]) {
let match;
try {
// Some urlpatterns will throw errors
match = m.match(path);
} catch (e) { continue }
if (match) {
request.params = match;
const response = await m.handler(request, meta);
if (response) {
const adaptable = responseShorthand(response);
return adaptResponse(adaptable, res);
}
}
}
// Not Found
const notFoundResponse = response({ status: 404 })
adaptResponse(notFoundResponse, res);
} catch (e) { throw e }
});
this._server.listen(port);
}
/**
* Stop listening for http requests
* Bound as a method to `HttpServer`
*/
function close() {
this._server.close();
}
/**
* Determine the port that's being listened over
* Bound as a method to `HttpServer`
*/
function port() {
return this._server.address().port;
}
/**
* Adapts `HttpResponse` it to its lower level counterpart`http.ServerResponse`.
* A helper function of `HttpServer.listen()`
*/
function adaptResponse(
input:HttpResponse,
output:http.ServerResponse
) {
const { status, headers, body } = input;
if (!(body instanceof Readable)) headers['Content-Length'] = Buffer.byteLength(body);
output.writeHead(status, { ...headers })
if (body instanceof Readable) {
body.pipe(output);
} else {
output.end(body);
}
}
/**
* Get stringified request body asyncronously
* @param {http.IncomingMessage} req
* @returns {Promise<string>}
*/
const asyncBody = promisify(function(req:http.IncomingMessage, callback) {
let body = '';
req.on('data', chunk => {
body += chunk.toString();
});
req.on('end', () => {
callback(null, body);
});
req.on('error', (e) => {
callback(e);
})
});
/**
* Convert primitive response types into intances of `HttpResponse`
* A helper function of `HttpServer.start()`
* A helper function of `HttpServer.listen()`
*/
function responseShorthand(response:any):HttpResponse {
if (typeof response == 'string') {
return {
status: 200,
headers: { 'content-type': 'text/html' },
body: response
}
}
else if (typeof response == 'number' && statusMessages[response]) {
return {
status: response,
headers: { 'content-type': 'text/plain' },
body: statusMessages[response]
}
}
else if (response instanceof Readable) {
return { status: 200, body: response }
}
else if (typeof response == 'object' && !(response instanceof HttpResponse)) {
return {
status: 200,
headers: { 'content-type': 'application/json' },
body: JSON.stringify(response),
}
}
else return response;
}
class Middleware {
method: 'string';
urlpattern: 'string';
handler: (req, meta) => any;
match: (url) => { [key:string]: string };
constructor(method, urlpattern, handler) {
this.method = method;
this.urlpattern = urlpattern;
this.handler = handler;
this.match = url => new UrlPattern(urlpattern).match(url);
}
}
function route(method, urlpattern, handler) {
method = method.toUpperCase();
if (!methods[method]) throw new Error(`Unsupported verb "${method}".`);
this.middlewares[method].push(new Middleware(method, urlpattern, handler));
}
class HttpResponse {
status?: number;
headers?: object;
body?: string | Readable;
constructor(options) {
const { status, headers, body } = options;
this.status = status ? status : 200;
this.headers = headers ? headers: {};
this.body = body != undefined ? body: '';
}
}
const statusMessages = {
200: 'OK',
201: 'Created',
400: 'Bad Request',
401: 'Unauthorized',
403: 'Forbidden',
404: 'Not Found',
409: 'Conflict',
500: 'Internal Server Error',
}
export const methods = {
GET: 'GET',
PUT: 'PUT',
POST: 'POST',
DELETE: 'DELETE'
}