@techiev2/vajra 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/vajra.jpg +0 -0
- package/assets/wrk-benchmarks.png +0 -0
- package/examples/api.js +38 -0
- package/examples/views/users.html +40 -0
- package/index.js +111 -0
- package/package.json +43 -0
- package/tests/tests.js +58 -0
- package/vajra.jpg +0 -0
package/assets/vajra.jpg
ADDED
|
Binary file
|
|
Binary file
|
package/examples/api.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import Vajra from '../index.js';
|
|
2
|
+
import { encode } from 'node:querystring';
|
|
3
|
+
|
|
4
|
+
async function getUsers(query = {}) {
|
|
5
|
+
return (await fetch(`https://jsonplaceholder.typicode.com/users?${encode(query)}`)).json()
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const { get, post, use, start, setProperty } = Vajra.create();
|
|
9
|
+
|
|
10
|
+
setProperty({ viewRoot: `${import.meta.dirname}/views` })
|
|
11
|
+
|
|
12
|
+
use((req, res, next) => {
|
|
13
|
+
console.log(`${req.method} ${req.url}`);
|
|
14
|
+
next();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
get('/', (req, res) => {
|
|
18
|
+
res.writeMessage('Hello from Vajra ⚡');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
post('/upload', (req, res) => {
|
|
22
|
+
res.json({ received: true, filesCount: req.files.length, files: req.files, body: req.body });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
start({ port: 4002 }, () => {
|
|
26
|
+
console.log('Ready at http://localhost:4002');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
get('/api/users', async ({ query }, res) => {
|
|
30
|
+
const users = await getUsers(query)
|
|
31
|
+
return res.json({ users })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
get('/web/users', async ({ query }, res) => {
|
|
35
|
+
const users = await getUsers(query)
|
|
36
|
+
const headers = Object.keys(users[0]).slice(0, 2)
|
|
37
|
+
return res.html(`users.html`, { users, headers })
|
|
38
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<style>
|
|
2
|
+
table {
|
|
3
|
+
font-family: Arial, Helvetica, sans-serif;
|
|
4
|
+
border-collapse: collapse;
|
|
5
|
+
width: 100%;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
td, th {
|
|
9
|
+
border: 1px solid #ddd;
|
|
10
|
+
padding: 8px;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
tr:nth-child(even){background-color: #f2f2f2;}
|
|
14
|
+
|
|
15
|
+
tr:hover {background-color: #ddd;}
|
|
16
|
+
|
|
17
|
+
th {
|
|
18
|
+
padding-top: 12px;
|
|
19
|
+
padding-bottom: 12px;
|
|
20
|
+
text-align: left;
|
|
21
|
+
background-color: #04AA6D;
|
|
22
|
+
color: white;
|
|
23
|
+
}
|
|
24
|
+
</style>
|
|
25
|
+
|
|
26
|
+
<table>
|
|
27
|
+
<thead>
|
|
28
|
+
{{# headers }}
|
|
29
|
+
<th>{ header@ }</th>
|
|
30
|
+
{{/ headers }}
|
|
31
|
+
</thead>
|
|
32
|
+
<tbody>
|
|
33
|
+
{{# users }}
|
|
34
|
+
<tr>
|
|
35
|
+
<td>{user.id}</td>
|
|
36
|
+
<td>{user.name}</td>
|
|
37
|
+
</tr>
|
|
38
|
+
{{/ users }}
|
|
39
|
+
</tbody>
|
|
40
|
+
</table>
|
package/index.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { createServer } from 'node:http'
|
|
2
|
+
import { readFile, access } from 'fs/promises'
|
|
3
|
+
|
|
4
|
+
const BLOCK_MATCHER=/{{\s*#\s*(?<grpStart>\w+)\s*}}\s*(?<block>.*?)\s*{{\s*\/\s*(?<grpEnd>\w+)\s*}}/gmsi; const INNER_BLOCK_MATCHER = /{\s*(.*?)\s*}/gmsi
|
|
5
|
+
const LOOP_MATCHER=/({\s*\w+@\s*})/gmis
|
|
6
|
+
|
|
7
|
+
function default_404({ url, method, isPossibleJSON }, res) {
|
|
8
|
+
const message = `Route ${url} not found for method ${method}.`
|
|
9
|
+
res.status(404); return isPossibleJSON ? res.json({ message }) : res.writeMessage(message)
|
|
10
|
+
}
|
|
11
|
+
function default_405({ url, method, isPossibleJSON }, res) {
|
|
12
|
+
const message = `Method ${method} not allowed by route ${url}.`
|
|
13
|
+
res.status(405); return isPossibleJSON ? res.json({ message }) : res.writeMessage(message)
|
|
14
|
+
}
|
|
15
|
+
function default_500({ url, method }, res, error) {
|
|
16
|
+
process.env.DEBUG && console.log({ error })
|
|
17
|
+
res.status(500).writeMessage(process.env.DEBUG ? `${error.stack}` : `Server error.\nRoute: ${url}\nMethod: ${method}\nTimestamp: ${new Date().getTime()}\n`)
|
|
18
|
+
}
|
|
19
|
+
function default_413(res) {
|
|
20
|
+
res.status(413).writeMessage('Payload Too Large')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const MAX_MB = 2; const MAX_FILE_SIZE = MAX_MB * 1024 * 1024
|
|
24
|
+
|
|
25
|
+
export default class Vajra {
|
|
26
|
+
static #app; static #routes = {}; static #middlewares = []; static #straightRoutes = {}; static #MAX_FILE_SIZE; static #onCreate; static #props = {}
|
|
27
|
+
static create({ maxFileSize } = { maxFileSize: 2 }) {
|
|
28
|
+
Vajra.#MAX_FILE_SIZE = !+MAX_FILE_SIZE ? +maxFileSize * 1024 * 1024 : MAX_FILE_SIZE
|
|
29
|
+
Vajra.#app = createServer()
|
|
30
|
+
Vajra.#app.on('request', async (req, res) => {
|
|
31
|
+
res.sent = false;
|
|
32
|
+
res.status = (/**@type{code} Number*/ code) => {
|
|
33
|
+
if (!+code || +code < 100 || +code > 599) { throw new Error(`Invalid status code ${code}`) }
|
|
34
|
+
res.statusCode = code; res.statusSet = true; return res
|
|
35
|
+
}
|
|
36
|
+
res.json = data => {
|
|
37
|
+
if (res.sent) return res
|
|
38
|
+
if (!res.statusSet) res.statusCode = 200
|
|
39
|
+
const response = JSON.stringify(data); res.sent = true; res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Length', Buffer.from(response).byteLength); res.write(response); res.end(); return res
|
|
40
|
+
}
|
|
41
|
+
res.writeMessage = (message = '') => {
|
|
42
|
+
if (res.sent) { return res }; if (!message) { res.status(500); message = 'Server error'; }
|
|
43
|
+
res.sent = true; res.setHeader('Content-Type', 'text/plain'); res.setHeader('Content-Length', Buffer.from(message).byteLength); res.write(message); res.end()
|
|
44
|
+
return res
|
|
45
|
+
}
|
|
46
|
+
res.html = async (templatePath, data = {}) => {
|
|
47
|
+
if (res.sent) { return res }; let content;
|
|
48
|
+
if (this.#props.viewRoot) templatePath = `${this.#props.viewRoot}/${templatePath}`
|
|
49
|
+
try { await access(templatePath); content = (await readFile(templatePath)).toString() } catch (_) { content = templatePath }
|
|
50
|
+
content.matchAll(BLOCK_MATCHER).forEach((match) => {
|
|
51
|
+
if (!match?.groups || (match.groups.grpStart !== match.groups.grpEnd)) { return }
|
|
52
|
+
const data_ = (data[match.groups.grpStart] || []); if (match.groups.block.indexOf('@') !== -1) { content = content.replace(match[2], data_.map((key) => match.groups.block.replace(LOOP_MATCHER, key)).join('')); return }
|
|
53
|
+
data_.forEach((dataItem) => { match.groups.block.matchAll(INNER_BLOCK_MATCHER).forEach((_match) => { const parts = _match[1].split('.').slice(1); let value = dataItem; parts.forEach((part) => (value = value ? value[part] : value)) ; content = content.replace(_match[0], value) }) })
|
|
54
|
+
}); content = content.replace(/{{\s*# .*?\s*}}/gmsi, '').replace(/{{\s*\/.*?}}/gmsi, '')
|
|
55
|
+
if (!res.statusSet) res.statusCode = 200
|
|
56
|
+
res.sent = true; res.setHeader('Content-Type', 'text/html'); res.setHeader('Content-Length', Buffer.from(content).byteLength); res.write(content); res.end(); return res
|
|
57
|
+
}
|
|
58
|
+
req._headers = { ...req.headers }; req.headers = Object.fromEntries(req.rawHeaders.map((e, i) => i % 2 ? false : [e, req.rawHeaders[i + 1]]).filter(Boolean)); req.isPossibleJSON = req._headers['content-type'] === 'application/json'; req.params = {}
|
|
59
|
+
let url = `http://${req.headers.host || req.headers.host}/${req.url}`; req.query = Object.fromEntries(new URL(url).searchParams)
|
|
60
|
+
if (req.method === 'GET' || req.method === 'HEAD') { return runMiddlwares() }
|
|
61
|
+
async function runMiddlwares() {
|
|
62
|
+
let idx = 0; const next = async () => { if (idx >= Vajra.#middlewares.length) { return setImmediate(handleRoute) } const fn = Vajra.#middlewares[idx]; idx++; try { await fn(req, res, next); } catch (err) { return default_500({ url: req.url, method: req.method }, res, err); } };
|
|
63
|
+
await next();
|
|
64
|
+
}
|
|
65
|
+
setImmediate(() => {
|
|
66
|
+
req.body = {}; req.rawData = ''; req.formData = {};let dataSize = 0
|
|
67
|
+
req.on('data', (chunk) => { dataSize += chunk.length; if (dataSize > Vajra.#MAX_FILE_SIZE) { return default_413(res) }; req.rawData+=chunk })
|
|
68
|
+
const formDataMatcher = /Content-Disposition: form-data; name=['"](?<name>[^"']+)['"]\s+(?<value>.*?)$/smi; const multiPartMatcher = /--------------------------.*?\r\n/gsmi
|
|
69
|
+
const fileDataMatcher = /^Content-Disposition:.*?name=["'](?<field>[^"']+)["'].*?filename=["'](?<fileName>[^"']+)["'].*?Content-Type:\s*(?<contentType>[^\r\n]*)\r?\n\r?\n(?<content>[\s\S]*)$/ims
|
|
70
|
+
req.on('end', async () => {
|
|
71
|
+
req.files = []; req.rawData.split(multiPartMatcher).filter(Boolean).map((line) => {
|
|
72
|
+
let key, value; if (line.includes('filename')) { req.files.push(fileDataMatcher.exec(line)?.groups || {}); return }
|
|
73
|
+
[key, value] = Object.values(line.match(formDataMatcher)?.groups || {}); (key && value) && Object.assign(req.formData, { [key]: value }); return
|
|
74
|
+
})
|
|
75
|
+
if (Object.keys(req.formData).length) { req.body = req.formData } else {
|
|
76
|
+
try { req.body = JSON.parse(req.rawData); req.isPossibleJSON = true } catch (_) { req.body = Object.fromEntries(req.rawData.split('&').map((pair) => pair.split('='))) }
|
|
77
|
+
}; setImmediate(runMiddlwares)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
async function handleRoute() {
|
|
81
|
+
let match_; const directHandler = (Vajra.#straightRoutes[req.url] || {})[req.method.toLowerCase()]
|
|
82
|
+
if (directHandler) { try { await directHandler(req, res); if (!res.sent && !res.writableEnded) res.end() } catch (error) { return default_500(req, res, error) }; return }
|
|
83
|
+
Object.entries(Vajra.#routes).map(([route, handler]) => {
|
|
84
|
+
if (match_) { return }
|
|
85
|
+
if (route === '/' && route !== req.url.split('?')[0]) { return } // FIXME: The case of a bare '/' is not handled right due to the pattern addition.
|
|
86
|
+
const match = new RegExp(route).exec(req.url); if (!!match && handler[req.method.toLowerCase()]) { match_ = handler; Object.assign(req.params, match.groups); return }
|
|
87
|
+
})
|
|
88
|
+
if (!match_) { return default_404(req, res) }
|
|
89
|
+
if (!match_[req.method.toLowerCase()]) { return default_405(req, res) }
|
|
90
|
+
try { await match_[req.method.toLowerCase()](req, res); if (!res.sent && !res.writableEnded) res.end() } catch (error) { return default_500(req, res, error) }
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
function setProperty(k, v) { Object.assign(Vajra.#props, typeof k == 'object' ? k: { k: v }); return defaults }
|
|
94
|
+
function start({ port, host = '127.0.0.1' }, cb) { Vajra.#app.listen(port, () => { console.log(`App listening at http://${host}:${port}`); if (typeof cb === 'function') { cb() } }); return defaults }
|
|
95
|
+
function register(method, path, handler) {
|
|
96
|
+
const paramMatcher = /.*?(?<param>\:[a-zA-Z]{1,})/g; let pathMatcherStr = path
|
|
97
|
+
path.matchAll(paramMatcher).forEach(match => pathMatcherStr = pathMatcherStr.replace(match.groups.param, `{0,1}(?<${match.groups.param.slice(1)}>\\w{1,})`))
|
|
98
|
+
if (path !== '/' && pathMatcherStr.endsWith('/')) { pathMatcherStr = pathMatcherStr.replace(/(\/)$/, '/?') }
|
|
99
|
+
/*pathMatcherStr = `^${pathMatcherStr}$`; */Vajra.#routes[pathMatcherStr] = Object.assign(Vajra.#routes[pathMatcherStr] || {}, {[method]: handler})
|
|
100
|
+
if (!paramMatcher.exec(path)?.groups) { Vajra.#straightRoutes[pathMatcherStr] = Object.assign(Vajra.#straightRoutes[pathMatcherStr] || {}, {[method]: handler}) }
|
|
101
|
+
return defaults
|
|
102
|
+
}
|
|
103
|
+
function use(fn) {
|
|
104
|
+
if (typeof fn !== "function") { throw new Error(`${fn} is not a function. Can't use as middleware`) }
|
|
105
|
+
Vajra.#middlewares.push(fn); return defaults
|
|
106
|
+
}
|
|
107
|
+
const defaults = Object.freeze(
|
|
108
|
+
Object.assign({}, { use, setProperty, start }, Object.fromEntries('get__post__put__patch__delete__head__options'.split('__').map((method) => [method, (...args) => register(method, ...args)]))
|
|
109
|
+
)); return Object.assign({}, { start }, defaults)
|
|
110
|
+
}
|
|
111
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@techiev2/vajra",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Blazing-fast, zero-dependency Node.js server with routing, middleware, multipart uploads, and templating. 111 lines · ~95k req/s · ~52 MB idle.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"http-server",
|
|
7
|
+
"web-framework",
|
|
8
|
+
"minimal",
|
|
9
|
+
"zero-dependency",
|
|
10
|
+
"lightweight",
|
|
11
|
+
"fast",
|
|
12
|
+
"node-http",
|
|
13
|
+
"routing",
|
|
14
|
+
"middleware",
|
|
15
|
+
"multipart",
|
|
16
|
+
"form-data",
|
|
17
|
+
"templating",
|
|
18
|
+
"html-template",
|
|
19
|
+
"performance",
|
|
20
|
+
"tiny",
|
|
21
|
+
"micro-framework",
|
|
22
|
+
"self-contained",
|
|
23
|
+
"no-dependencies"
|
|
24
|
+
],
|
|
25
|
+
"homepage": "https://github.com/techiev2/vajra#readme",
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/techiev2/vajra/issues"
|
|
28
|
+
},
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+ssh://git@github.com/techiev2/vajra.git"
|
|
32
|
+
},
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"author": "Sriram Velamur <sriram.velamur@gmail.com>",
|
|
35
|
+
"type": "module",
|
|
36
|
+
"main": "index.js",
|
|
37
|
+
"directories": {
|
|
38
|
+
"test": "tests"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"test": "tests/tests.js"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/tests/tests.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
import { encode } from 'node:querystring';
|
|
3
|
+
import { suite, test } from 'node:test';
|
|
4
|
+
|
|
5
|
+
async function getJSON(url, method = 'GET', body) {
|
|
6
|
+
return (await fetch(url, !!body ?
|
|
7
|
+
{ method, headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) }
|
|
8
|
+
: { method, headers: {'Content-Type': 'application/json'} }
|
|
9
|
+
)).json()
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
suite('Test HTTP API at port 4000', () => {
|
|
13
|
+
const BASE_URL = 'http://localhost:4000'
|
|
14
|
+
suite('Test HTTP GET', () => {
|
|
15
|
+
test('Basic HTTP GET to respond with a now and empty query/params', async () => {
|
|
16
|
+
const { now, query: res_query, params: res_params } = await getJSON(BASE_URL)
|
|
17
|
+
assert.equal(!!now, true)
|
|
18
|
+
assert.deepEqual(res_query, {})
|
|
19
|
+
assert.deepEqual(res_params, {})
|
|
20
|
+
})
|
|
21
|
+
test('Basic HTTP GET to respond with a now, and query params', async () => {
|
|
22
|
+
const query = { id: 1, user: 'test' }
|
|
23
|
+
const { now, query: res_query, params: res_params } = await getJSON(`${BASE_URL}?${encode(query)}`)
|
|
24
|
+
assert.equal(!!now, true)
|
|
25
|
+
assert.deepEqual(res_query, query)
|
|
26
|
+
assert.deepEqual(res_params, {})
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
suite('Test HTTP POST', () => {
|
|
30
|
+
// test('Basic HTTP POST to respond with a now and body', async () => {
|
|
31
|
+
// const query = { id: 1, user: 'test' }
|
|
32
|
+
// const body = { name: 'user' }
|
|
33
|
+
// const { now, query: res_query, body: res_body } = await getJSON(`${BASE_URL}`, 'POST', body)
|
|
34
|
+
// assert.equal(!!now, true)
|
|
35
|
+
// assert.deepEqual(res_query, query)
|
|
36
|
+
// assert.deepEqual(res_body, body)
|
|
37
|
+
// })
|
|
38
|
+
test('Basic HTTP POST with a param shoudl return a 404', async () => {
|
|
39
|
+
// const { now, query: res_query, body: res_body } =
|
|
40
|
+
const body = { name: 'test' }
|
|
41
|
+
const json = await getJSON(`${BASE_URL}/users/1?test=random`, 'POST', body)
|
|
42
|
+
console.log(json)
|
|
43
|
+
// assert.equal(!!now, true)
|
|
44
|
+
// assert.deepEqual(res_query, query)
|
|
45
|
+
// assert.deepEqual(res_body, body)
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
suite('Test HTTP PUT', () => {
|
|
49
|
+
test('Basic HTTP PUT to respond with a now, query, params, and body', async () => {
|
|
50
|
+
const params = { id : 1 }
|
|
51
|
+
const body = { name: 'user' }
|
|
52
|
+
const { now, params: res_params, body: res_body} = await getJSON(`${BASE_URL}/users/${params.id}`, 'PUT', body)
|
|
53
|
+
assert.equal(!!now, true)
|
|
54
|
+
assert.deepEqual(res_params, params)
|
|
55
|
+
assert.deepEqual(res_body, body)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
})
|
package/vajra.jpg
ADDED
|
Binary file
|