@techiev2/vajra 1.3.0 → 1.4.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/README +30 -6
- package/README.md +19 -0
- package/index.js +1 -0
- package/libs/auth/jwt.js +23 -0
- package/package.json +1 -1
- package/tests/tests.js +136 -1
package/README
CHANGED
|
@@ -14,15 +14,35 @@ Vajra draws from the Rigvedic thunderbolt weapon of Indra — crafted from the b
|
|
|
14
14
|
Like the Vajra, this server delivers maximum power in minimal form.
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
[](https://www.npmjs.com/package/vajra)
|
|
18
|
-
[](https://www.npmjs.com/package/vajra)
|
|
19
|
-
[](https://nodejs.org)
|
|
20
|
-
[](LICENSE)
|
|
17
|
+
[](https://www.npmjs.com/package/@techiev2/vajra)
|
|
18
|
+
[](https://www.npmjs.com/package/@techiev2/vajra)
|
|
19
|
+
[](https://nodejs.org)
|
|
20
|
+
[](LICENSE)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
## Changelog
|
|
24
|
+
|
|
25
|
+
### 1.4.0 (Current)
|
|
26
|
+
- Added full HS256 JWT support (`@techiev2/vajra/libs/auth/jwt.js`)
|
|
27
|
+
- Ultra-minimal, zero-dependency implementation
|
|
28
|
+
- Key and header caching for maximum performance
|
|
29
|
+
- Robust base64url handling
|
|
30
|
+
- Numeric exp validation and expiration checks
|
|
31
|
+
|
|
32
|
+
### 1.3.0 (2025-12-30)
|
|
33
|
+
- Performance improvements to routing in bare routes
|
|
34
|
+
|
|
35
|
+
### 1.2.0 (2025-12-30)
|
|
36
|
+
- Adds cookie support
|
|
37
|
+
|
|
38
|
+
### 1.0.0 (2025-12-25)
|
|
39
|
+
- Initial release
|
|
21
40
|
|
|
22
41
|
## Features
|
|
23
42
|
|
|
24
43
|
- Zero external dependencies
|
|
25
44
|
- Built-in routing with named parameters (`:id`)
|
|
45
|
+
- Asynchronous batched logging for performance
|
|
26
46
|
- Global middleware support with `next()` chaining
|
|
27
47
|
- JSON, urlencoded, and **multipart/form-data** body parsing
|
|
28
48
|
- Fast HTML templating with loops, nested objects, and simple array headers
|
|
@@ -59,14 +79,17 @@ async function getUsers(query = {}) {
|
|
|
59
79
|
return (await fetch(`https://jsonplaceholder.typicode.com/users?${encode(query)}`)).json()
|
|
60
80
|
}
|
|
61
81
|
|
|
62
|
-
const { get, post, use, start, setProperty } = Vajra.create();
|
|
82
|
+
const { get, post, use, start, setProperty, log } = Vajra.create();
|
|
63
83
|
|
|
64
84
|
setProperty({ viewsRoot: `${import.meta.url}/views` })
|
|
65
85
|
// Or as a key-value pair
|
|
66
86
|
// setProperty('viewsRoot', `${import.meta.url}/views`)
|
|
67
87
|
|
|
68
88
|
use((req, res, next) => {
|
|
69
|
-
|
|
89
|
+
// Vajra provides an async batched logger to provide a balance between 100% log coverage and performance.
|
|
90
|
+
// If you prefer blocking immediate logs, you can switch to console.log
|
|
91
|
+
// or any other library of your choice.
|
|
92
|
+
log(`${req.method} ${req.url}`);
|
|
70
93
|
next();
|
|
71
94
|
});
|
|
72
95
|
|
|
@@ -156,6 +179,7 @@ app.setProperty('viewRoot', './views');
|
|
|
156
179
|
- `use(middleware)`
|
|
157
180
|
- `start({ port, host }, callback?)`
|
|
158
181
|
- `setProperty(key, value)` or `setProperty({ key: value })`
|
|
182
|
+
- `log(message)`
|
|
159
183
|
|
|
160
184
|
|
|
161
185
|
#### Response helpers:
|
package/README.md
CHANGED
|
@@ -19,6 +19,25 @@ Like the Vajra, this server delivers maximum power in minimal form.
|
|
|
19
19
|
[](https://nodejs.org)
|
|
20
20
|
[](LICENSE)
|
|
21
21
|
|
|
22
|
+
|
|
23
|
+
## Changelog
|
|
24
|
+
|
|
25
|
+
### 1.4.0 (Current)
|
|
26
|
+
- Added full HS256 JWT support (`@techiev2/vajra/libs/auth/jwt.js`)
|
|
27
|
+
- Ultra-minimal, zero-dependency implementation
|
|
28
|
+
- Key and header caching for maximum performance
|
|
29
|
+
- Robust base64url handling
|
|
30
|
+
- Numeric exp validation and expiration checks
|
|
31
|
+
|
|
32
|
+
### 1.3.0 (2025-12-30)
|
|
33
|
+
- Performance improvements to routing in bare routes
|
|
34
|
+
|
|
35
|
+
### 1.2.0 (2025-12-30)
|
|
36
|
+
- Adds cookie support
|
|
37
|
+
|
|
38
|
+
### 1.0.0 (2025-12-25)
|
|
39
|
+
- Initial release
|
|
40
|
+
|
|
22
41
|
## Features
|
|
23
42
|
|
|
24
43
|
- Zero external dependencies
|
package/index.js
CHANGED
|
@@ -31,6 +31,7 @@ export default class Vajra {
|
|
|
31
31
|
process.on('exit', logOut); function log(message) { _queue.push(`${message}\n`); if (_queue.length >= LOG_QUEUE_SIZE) { logOut(); _queue.length = 0; } }
|
|
32
32
|
Vajra.#MAX_FILE_SIZE = !+MAX_FILE_SIZE ? +maxFileSize * 1024 * 1024 : MAX_FILE_SIZE
|
|
33
33
|
Vajra.#app.on('request', async (req, res) => {
|
|
34
|
+
if (+(req.headers['Content-Length'] || req.headers['content-length']) > Vajra.#MAX_FILE_SIZE) { return default_413(res) }
|
|
34
35
|
res.sent = false;
|
|
35
36
|
res.status = (/**@type{code} Number*/ code) => {
|
|
36
37
|
if (!+code || +code < 100 || +code > 599) { throw new Error(`Invalid status code ${code}`) }
|
package/libs/auth/jwt.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { subtle } from 'node:crypto';const hasBuffer = typeof Buffer !== 'undefined';
|
|
2
|
+
const DATA = { encoder: new TextEncoder(), decoder: new TextDecoder(), keys: {}, headers: { sign: { alg: 'HS256', typ: 'JWT' }, verify: { name: 'HMAC', hash: 'SHA-256' } } };
|
|
3
|
+
const ENCODED_HEADERS = {}
|
|
4
|
+
function encode(buf) { if (hasBuffer) return Buffer.from(buf).toString('base64url'); let s = ''; for (let i = 0; i < buf.length; i++) s += String.fromCharCode(buf[i]); return btoa(s).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,''); }
|
|
5
|
+
function decode(str) { if (hasBuffer) return Buffer.from(str, 'base64url'); str = str.replace(/-/g,'+').replace(/_/g,'/'); while (str.length % 4) str += '='; const bin = atob(str); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out;}
|
|
6
|
+
async function populateKeyCache(secret) {
|
|
7
|
+
let { sign: signKey, verify: verifyKey } = DATA.keys[`${secret}`] || {};
|
|
8
|
+
if (!signKey || !verifyKey) { [signKey, verifyKey] = await Promise.all([subtle.importKey('raw', DATA.encoder.encode(secret), DATA.headers.verify, false, ['sign']), subtle.importKey('raw', DATA.encoder.encode(secret), DATA.headers.verify, false, ['verify'])]); Object.assign(DATA.keys, { [secret]: { sign: signKey, verify: verifyKey } }) }
|
|
9
|
+
return { signKey, verifyKey }
|
|
10
|
+
}
|
|
11
|
+
export async function sign(payload, secret, options = {}) {
|
|
12
|
+
if (!payload || typeof payload !== 'object') throw new Error("Payload to sign must be an object"); if (typeof secret !== 'string') throw new Error("Secret must be a string")
|
|
13
|
+
const { signKey } = await populateKeyCache(secret); const { alg = 'HS256' } = options;
|
|
14
|
+
ENCODED_HEADERS[alg] = ENCODED_HEADERS[alg] || DATA.encoder.encode({ ...DATA.headers.sign, alg }); const encodedPayload = encode(DATA.encoder.encode(JSON.stringify(payload)))
|
|
15
|
+
const encodedSignature = encode(await subtle.sign('HMAC', signKey, DATA.encoder.encode(`${ENCODED_HEADERS[alg]}.${encodedPayload}`))); return `${ENCODED_HEADERS[alg]}.${encodedPayload}.${encodedSignature}`;
|
|
16
|
+
}
|
|
17
|
+
export async function verify(token, secret) {
|
|
18
|
+
if (!token || typeof token != 'string') throw new Error("Token must be a string"); if (!secret || typeof secret !== "string") throw new Error("Secret must be a string")
|
|
19
|
+
const [encodedHeader, encodedPayload, encodedSignature, ..._] = token.split('.'); if (!encodedPayload || !encodedSignature || _.length) throw new Error('Invalid token format');
|
|
20
|
+
const { verifyKey } = await populateKeyCache(secret); const valid = await subtle.verify('HMAC', verifyKey, decode(encodedSignature), DATA.encoder.encode(`${encodedHeader}.${encodedPayload}`));
|
|
21
|
+
if (!valid) throw new Error('Invalid signature'); const payload = JSON.parse(DATA.decoder.decode(decode(encodedPayload)));
|
|
22
|
+
if (payload.exp && isNaN(+payload.exp)) throw new Error("Expiry must be numeric."); if (payload.exp && Date.now() >= payload.exp * 1000) { throw new Error('Token expired'); } return payload;
|
|
23
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@techiev2/vajra",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
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
5
|
"keywords": [
|
|
6
6
|
"http-server",
|
package/tests/tests.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import assert from 'node:assert';
|
|
2
2
|
import { encode } from 'node:querystring';
|
|
3
|
-
import pkg from '../package.json' with {type: 'json'}
|
|
4
3
|
import { suite, test } from 'node:test';
|
|
4
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
import pkg from '../package.json' with {type: 'json'}
|
|
7
|
+
import { sign, verify } from '../libs/auth/jwt.js';
|
|
5
8
|
|
|
6
9
|
async function getResponse(url, method = 'GET', body) {
|
|
7
10
|
return (await fetch(url, !!body ?
|
|
@@ -59,3 +62,135 @@ suite('Test HTTP API at port 4002', () => {
|
|
|
59
62
|
})
|
|
60
63
|
})
|
|
61
64
|
})
|
|
65
|
+
|
|
66
|
+
suite('Tests for library functions', () => {
|
|
67
|
+
const encoder = new TextEncoder(); const signHeaders = { alg: 'HS256', typ: 'JWT' }
|
|
68
|
+
test('Verify that JWT helper returns the right token', async () => {
|
|
69
|
+
const secret = randomBytes(16).toString('hex')
|
|
70
|
+
const data = {
|
|
71
|
+
now: new Date().getTime(),
|
|
72
|
+
id: randomUUID()
|
|
73
|
+
}
|
|
74
|
+
const token = await sign(data, secret)
|
|
75
|
+
assert.strictEqual(!!token, true)
|
|
76
|
+
const verified = await verify(token, secret)
|
|
77
|
+
assert.strict.deepEqual(data, verified)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('Verify that JWT helper throws an expired token error with exp set to gen time', async () => {
|
|
81
|
+
const secret = randomBytes(16).toString('hex')
|
|
82
|
+
let exp = Date.now() / 1000
|
|
83
|
+
const data = {
|
|
84
|
+
now: new Date().getTime(),
|
|
85
|
+
exp,
|
|
86
|
+
id: randomUUID()
|
|
87
|
+
}
|
|
88
|
+
const token = await sign(data, secret)
|
|
89
|
+
assert.strictEqual(!!token, true)
|
|
90
|
+
assert.rejects(verify.bind(null, token, secret), Error);
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('rejects malformed token formats', async () => {
|
|
94
|
+
const secret = 's';
|
|
95
|
+
await assert.rejects(verify('', secret),);
|
|
96
|
+
await assert.rejects(verify('a', secret),);
|
|
97
|
+
await assert.rejects(verify('a.b', secret),);
|
|
98
|
+
await assert.rejects(verify('a.b.c.d', secret),);
|
|
99
|
+
await assert.rejects(verify('a..c', secret),);
|
|
100
|
+
await assert.rejects(verify('a.b.', secret),);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('rejects tampered signature', async () => {
|
|
104
|
+
const token = await sign({a:1}, 'secret');
|
|
105
|
+
const tampered = token.slice(0, -1) + (token.at(-1) === 'A' ? 'B' : 'A');
|
|
106
|
+
await assert.rejects(verify(tampered, 'secret'), /Invalid signature/);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('rejects tampered payload', async () => {
|
|
110
|
+
const token = await sign({a:1}, 'secret');
|
|
111
|
+
const parts = token.split('.');
|
|
112
|
+
parts[1] = parts[1].slice(0, -1) + 'X';
|
|
113
|
+
await assert.rejects(verify(parts.join('.'), 'secret'), /Invalid signature/);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('allows token without exp claim', async () => {
|
|
117
|
+
const secret = 's';
|
|
118
|
+
const payload = { sub: '123', iat: Math.floor(Date.now() / 1000) };
|
|
119
|
+
const token = await sign(payload, secret);
|
|
120
|
+
const verified = await verify(token, secret);
|
|
121
|
+
assert.deepStrictEqual(verified, payload);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('rejects token with future exp after clock advances', async () => {
|
|
125
|
+
const secret = 's';
|
|
126
|
+
const past = Math.floor(Date.now() / 1000) - 3600;
|
|
127
|
+
const payload = { exp: past };
|
|
128
|
+
const token = await sign(payload, secret);
|
|
129
|
+
await assert.rejects(verify(token, secret), /Token expired/);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('handles large payload without truncation', async () => {
|
|
133
|
+
const secret = 's';
|
|
134
|
+
const large = { data: 'x'.repeat(5000), arr: Array(100).fill(42) };
|
|
135
|
+
const token = await sign(large, secret);
|
|
136
|
+
const verified = await verify(token, secret);
|
|
137
|
+
assert.deepStrictEqual(verified, large);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('rejects invalid JSON in payload segment', async () => {
|
|
141
|
+
const header = encode(encoder.encode(JSON.stringify(signHeaders)));
|
|
142
|
+
const badPayload = encode(encoder.encode('{"broken'));
|
|
143
|
+
const fakeSig = 'AAAA';
|
|
144
|
+
const badToken = `${header}.${badPayload}.${fakeSig}`;
|
|
145
|
+
await assert.rejects(verify(badToken, 'secret'), /Invalid signature/);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('rejects non-object payload on sign', async () => {
|
|
149
|
+
await assert.rejects(sign('string', 'secret'));
|
|
150
|
+
await assert.rejects(sign(null, 'secret'));
|
|
151
|
+
await assert.rejects(sign(123, 'secret'));
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Add to existing suite
|
|
155
|
+
test('rejects non-numeric exp claim', async () => {
|
|
156
|
+
const secret = 's';
|
|
157
|
+
const payload = { exp: 'invalid', sub: '123' };
|
|
158
|
+
const token = await sign(payload, secret);
|
|
159
|
+
await assert.rejects(verify(token, secret), /Expiry must be numeric/);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('handles clock skew edge case', async () => {
|
|
163
|
+
const secret = 's';
|
|
164
|
+
const now = Math.floor(Date.now() / 1000);
|
|
165
|
+
const payload = { exp: now + 1 }; // Expires in 1s
|
|
166
|
+
const token = await sign(payload, secret);
|
|
167
|
+
await new Promise(resolve => setTimeout(resolve, 2000)); // Wait 2s
|
|
168
|
+
await assert.rejects(verify(token, secret), /Token expired/);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('rejects malformed base64url segments', async () => {
|
|
172
|
+
const secret = 's';
|
|
173
|
+
const header = encode(encoder.encode(JSON.stringify(signHeaders)));
|
|
174
|
+
const validPayload = encode(encoder.encode('{"sub":"123"}'));
|
|
175
|
+
const badSegment = '!!invalid@@';
|
|
176
|
+
await assert.rejects(verify(`${header}.${badSegment}.AAAA`, secret), /Invalid signature/);
|
|
177
|
+
await assert.rejects(verify(`${header}.${validPayload}.!!invalid@@`, secret), /Invalid signature/);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('handles empty payload object', async () => {
|
|
181
|
+
const secret = 's';
|
|
182
|
+
const payload = {};
|
|
183
|
+
const token = await sign(payload, secret);
|
|
184
|
+
const verified = await verify(token, secret);
|
|
185
|
+
assert.deepStrictEqual(verified, payload);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('rejects very weak secret', async () => {
|
|
189
|
+
const secret = 'a'; // Short but still works (crypto allows it)
|
|
190
|
+
const payload = { sub: '123' };
|
|
191
|
+
const token = await sign(payload, secret);
|
|
192
|
+
const verified = await verify(token, secret);
|
|
193
|
+
assert.deepStrictEqual(verified, payload);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
})
|