@rexaray008/nodeproxy 1.0.1
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.md +100 -0
- package/bin/cli.js +80 -0
- package/lib/config.js +73 -0
- package/lib/server.js +168 -0
- package/nodeproxy.config.json +26 -0
- package/package.json +20 -0
package/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# nodeproxy
|
|
2
|
+
|
|
3
|
+
A tiny, zero-dependency reverse proxy / web server for Node.js — think "a mini nginx" you can read in one sitting. No native code, no config-language DSL, just JSON.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
- Host-based virtual routing (`api.example.com` → one backend, `app.example.com` → another)
|
|
7
|
+
- Round-robin load balancing across multiple upstream targets per host
|
|
8
|
+
- Wildcard `*` catch-all host for local dev
|
|
9
|
+
- Static file fallback (serve a folder directly, no backend needed)
|
|
10
|
+
- HTTPS support (bring your own cert/key)
|
|
11
|
+
- Zero npm dependencies — pure Node.js `http`/`https` core modules
|
|
12
|
+
|
|
13
|
+
## One-command quick start
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx nodeproxy start
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
That's it. On first run this:
|
|
20
|
+
1. Creates `nodeproxy.config.json` in the current directory with sensible defaults
|
|
21
|
+
2. Starts listening on port `8080`, forwarding everything to `http://localhost:3000`
|
|
22
|
+
|
|
23
|
+
Edit `nodeproxy.config.json` and restart to change routing, or use the CLI helper below.
|
|
24
|
+
|
|
25
|
+
### Install globally instead
|
|
26
|
+
```bash
|
|
27
|
+
npm install -g nodeproxy
|
|
28
|
+
nodeproxy start
|
|
29
|
+
```
|
|
30
|
+
(publish this folder to npm first, or just `npm link` it locally — see below)
|
|
31
|
+
|
|
32
|
+
### Run without installing (from this folder)
|
|
33
|
+
```bash
|
|
34
|
+
node bin/cli.js start
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Configuring routes
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# Route traffic for a host to a backend
|
|
41
|
+
nodeproxy add example.com http://localhost:4000
|
|
42
|
+
|
|
43
|
+
# Add a second target on the same host for load balancing
|
|
44
|
+
# (just edit the "targets" array in nodeproxy.config.json directly)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Example `nodeproxy.config.json`:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"port": 8080,
|
|
52
|
+
"https": { "enabled": false, "port": 8443, "key": "", "cert": "" },
|
|
53
|
+
"logging": true,
|
|
54
|
+
"routes": [
|
|
55
|
+
{ "host": "app.example.com", "targets": ["http://localhost:4000", "http://localhost:4001"], "staticDir": null },
|
|
56
|
+
{ "host": "static.example.com", "targets": [], "staticDir": "./public" },
|
|
57
|
+
{ "host": "*", "targets": ["http://localhost:3000"], "staticDir": null }
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
- `targets` with 2+ entries = automatic round-robin load balancing
|
|
63
|
+
- `staticDir` set + empty `targets` = pure static file server for that host
|
|
64
|
+
- `host: "*"` = catch-all, used when no other host matches (handy for local dev where you don't care about the `Host` header)
|
|
65
|
+
|
|
66
|
+
## HTTPS
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
"https": {
|
|
70
|
+
"enabled": true,
|
|
71
|
+
"port": 8443,
|
|
72
|
+
"key": "/path/to/privkey.pem",
|
|
73
|
+
"cert": "/path/to/fullchain.pem"
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Get free certs from Let's Encrypt (e.g. via `certbot`) and point `key`/`cert` at the files. HTTP and HTTPS run side-by-side.
|
|
78
|
+
|
|
79
|
+
## Local install / publish
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
npm link # makes the `nodeproxy` command available globally on this machine
|
|
83
|
+
# or
|
|
84
|
+
npm publish # to publish it as a real npm package
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Project layout
|
|
88
|
+
```
|
|
89
|
+
bin/cli.js CLI entrypoint (start / init / add / help)
|
|
90
|
+
lib/server.js Core proxy engine (routing, load balancing, static serving)
|
|
91
|
+
lib/config.js Config file loading/creation
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Limitations (intentional — this is a learning-scale project, not production nginx)
|
|
95
|
+
- No automatic Let's Encrypt / ACME renewal — bring your own certs
|
|
96
|
+
- No SNI-based multi-cert HTTPS (one cert for all HTTPS hosts)
|
|
97
|
+
- No rate limiting, caching, or WAF features
|
|
98
|
+
- Round-robin only (no least-connections/weighted balancing)
|
|
99
|
+
|
|
100
|
+
These are all reasonable next steps if you want to extend it.
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { loadConfig, saveConfig, ensureConfig, DEFAULT_CONFIG_NAME } = require('../lib/config');
|
|
5
|
+
const { startServer } = require('../lib/server');
|
|
6
|
+
|
|
7
|
+
function usage() {
|
|
8
|
+
console.log(`
|
|
9
|
+
nodeproxy - a tiny reverse proxy / web server (like a mini nginx)
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
nodeproxy start [--config <file>] Start the proxy (auto-creates a
|
|
13
|
+
default config on first run)
|
|
14
|
+
nodeproxy init [--config <file>] Just create the config file
|
|
15
|
+
nodeproxy add <host> <target> [--config <file>]
|
|
16
|
+
Add/update a route, e.g.
|
|
17
|
+
nodeproxy add example.com http://localhost:4000
|
|
18
|
+
nodeproxy help Show this message
|
|
19
|
+
|
|
20
|
+
One-command quick start:
|
|
21
|
+
npx nodeproxy start
|
|
22
|
+
`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getFlag(args, name) {
|
|
26
|
+
const i = args.indexOf(name);
|
|
27
|
+
if (i === -1) return undefined;
|
|
28
|
+
return args[i + 1];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function main() {
|
|
32
|
+
const args = process.argv.slice(2);
|
|
33
|
+
const cmd = args[0] || 'start';
|
|
34
|
+
const configPath = getFlag(args, '--config');
|
|
35
|
+
|
|
36
|
+
if (cmd === 'help' || cmd === '-h' || cmd === '--help') {
|
|
37
|
+
return usage();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (cmd === 'init') {
|
|
41
|
+
const file = ensureConfig(configPath);
|
|
42
|
+
console.log(`[nodeproxy] Config ready at ${file}`);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (cmd === 'add') {
|
|
47
|
+
const host = args[1];
|
|
48
|
+
const target = args[2];
|
|
49
|
+
if (!host || !target) {
|
|
50
|
+
console.error('Usage: nodeproxy add <host> <target>');
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
const { config, file } = loadConfig(configPath);
|
|
54
|
+
const existing = config.routes.find(r => r.host === host);
|
|
55
|
+
if (existing) {
|
|
56
|
+
existing.targets = [target];
|
|
57
|
+
} else {
|
|
58
|
+
config.routes.push({ host, targets: [target], staticDir: null });
|
|
59
|
+
}
|
|
60
|
+
saveConfig(config, configPath);
|
|
61
|
+
console.log(`[nodeproxy] Route added: ${host} -> ${target} (${file})`);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (cmd === 'start') {
|
|
66
|
+
const { config, file } = loadConfig(configPath);
|
|
67
|
+
console.log(`[nodeproxy] Using config: ${file}`);
|
|
68
|
+
if (file.endsWith(DEFAULT_CONFIG_NAME)) {
|
|
69
|
+
console.log('[nodeproxy] (edit this file, or use "nodeproxy add <host> <target>", to change routing)');
|
|
70
|
+
}
|
|
71
|
+
startServer(config);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
console.error(`Unknown command: ${cmd}`);
|
|
76
|
+
usage();
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
main();
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const DEFAULT_CONFIG_NAME = 'nodeproxy.config.json';
|
|
7
|
+
|
|
8
|
+
function defaultConfig() {
|
|
9
|
+
return {
|
|
10
|
+
// Port the proxy itself listens on
|
|
11
|
+
port: 8080,
|
|
12
|
+
|
|
13
|
+
// Optional HTTPS. Leave "enabled": false until you have real certs.
|
|
14
|
+
https: {
|
|
15
|
+
enabled: false,
|
|
16
|
+
port: 8443,
|
|
17
|
+
key: '', // path to privkey.pem
|
|
18
|
+
cert: '' // path to fullchain.pem
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
// Request logging to stdout
|
|
22
|
+
logging: true,
|
|
23
|
+
|
|
24
|
+
// Gzip/deflate pass-through is automatic (we just forward headers),
|
|
25
|
+
// nothing to configure there.
|
|
26
|
+
|
|
27
|
+
// Virtual hosts / routes.
|
|
28
|
+
// "host" supports "*" as a catch-all for local testing.
|
|
29
|
+
// "targets" is a list -> automatic round-robin load balancing.
|
|
30
|
+
routes: [
|
|
31
|
+
{
|
|
32
|
+
host: '*',
|
|
33
|
+
targets: ['http://localhost:3000'],
|
|
34
|
+
// If set, static files are served from this directory when
|
|
35
|
+
// no upstream target responds (or as the sole behavior if
|
|
36
|
+
// targets is empty).
|
|
37
|
+
staticDir: null
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function configPath(customPath) {
|
|
44
|
+
return customPath
|
|
45
|
+
? path.resolve(process.cwd(), customPath)
|
|
46
|
+
: path.resolve(process.cwd(), DEFAULT_CONFIG_NAME);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function ensureConfig(customPath) {
|
|
50
|
+
const file = configPath(customPath);
|
|
51
|
+
if (!fs.existsSync(file)) {
|
|
52
|
+
fs.writeFileSync(file, JSON.stringify(defaultConfig(), null, 2) + '\n');
|
|
53
|
+
}
|
|
54
|
+
return file;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function loadConfig(customPath) {
|
|
58
|
+
const file = ensureConfig(customPath);
|
|
59
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
60
|
+
try {
|
|
61
|
+
return { config: JSON.parse(raw), file };
|
|
62
|
+
} catch (err) {
|
|
63
|
+
throw new Error(`Failed to parse ${file}: ${err.message}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function saveConfig(config, customPath) {
|
|
68
|
+
const file = configPath(customPath);
|
|
69
|
+
fs.writeFileSync(file, JSON.stringify(config, null, 2) + '\n');
|
|
70
|
+
return file;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = { defaultConfig, configPath, ensureConfig, loadConfig, saveConfig, DEFAULT_CONFIG_NAME };
|
package/lib/server.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const http = require('http');
|
|
4
|
+
const https = require('https');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { URL } = require('url');
|
|
8
|
+
|
|
9
|
+
const MIME = {
|
|
10
|
+
'.html': 'text/html; charset=utf-8',
|
|
11
|
+
'.htm': 'text/html; charset=utf-8',
|
|
12
|
+
'.css': 'text/css; charset=utf-8',
|
|
13
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
14
|
+
'.json': 'application/json; charset=utf-8',
|
|
15
|
+
'.png': 'image/png',
|
|
16
|
+
'.jpg': 'image/jpeg',
|
|
17
|
+
'.jpeg': 'image/jpeg',
|
|
18
|
+
'.gif': 'image/gif',
|
|
19
|
+
'.svg': 'image/svg+xml',
|
|
20
|
+
'.ico': 'image/x-icon',
|
|
21
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
22
|
+
'.woff': 'font/woff',
|
|
23
|
+
'.woff2': 'font/woff2'
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// --- round-robin state, one counter per route ---
|
|
27
|
+
const counters = new WeakMap();
|
|
28
|
+
|
|
29
|
+
function pickTarget(route) {
|
|
30
|
+
if (!route.targets || route.targets.length === 0) return null;
|
|
31
|
+
let i = counters.get(route) || 0;
|
|
32
|
+
const target = route.targets[i % route.targets.length];
|
|
33
|
+
counters.set(route, i + 1);
|
|
34
|
+
return target;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function matchRoute(routes, hostHeader) {
|
|
38
|
+
const host = (hostHeader || '').split(':')[0].toLowerCase();
|
|
39
|
+
let exact = null;
|
|
40
|
+
let wildcard = null;
|
|
41
|
+
for (const r of routes) {
|
|
42
|
+
if (r.host === host) exact = r;
|
|
43
|
+
if (r.host === '*') wildcard = r;
|
|
44
|
+
}
|
|
45
|
+
return exact || wildcard || null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function log(config, ...args) {
|
|
49
|
+
if (config.logging) {
|
|
50
|
+
console.log(new Date().toISOString(), ...args);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function serveStatic(dir, reqUrl, res) {
|
|
55
|
+
const safePath = path.normalize(path.join(dir, decodeURIComponent(reqUrl.split('?')[0])));
|
|
56
|
+
if (!safePath.startsWith(path.resolve(dir))) {
|
|
57
|
+
res.writeHead(403);
|
|
58
|
+
return res.end('Forbidden');
|
|
59
|
+
}
|
|
60
|
+
let filePath = safePath;
|
|
61
|
+
fs.stat(filePath, (err, stat) => {
|
|
62
|
+
if (err) {
|
|
63
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
64
|
+
return res.end('404 Not Found');
|
|
65
|
+
}
|
|
66
|
+
if (stat.isDirectory()) {
|
|
67
|
+
filePath = path.join(filePath, 'index.html');
|
|
68
|
+
}
|
|
69
|
+
const ext = path.extname(filePath);
|
|
70
|
+
fs.readFile(filePath, (err2, data) => {
|
|
71
|
+
if (err2) {
|
|
72
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
73
|
+
return res.end('404 Not Found');
|
|
74
|
+
}
|
|
75
|
+
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
|
|
76
|
+
res.end(data);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function proxyRequest(target, req, res, config) {
|
|
82
|
+
const targetUrl = new URL(target);
|
|
83
|
+
const options = {
|
|
84
|
+
hostname: targetUrl.hostname,
|
|
85
|
+
port: targetUrl.port || (targetUrl.protocol === 'https:' ? 443 : 80),
|
|
86
|
+
path: req.url,
|
|
87
|
+
method: req.method,
|
|
88
|
+
headers: Object.assign({}, req.headers, {
|
|
89
|
+
'x-forwarded-for': req.socket.remoteAddress,
|
|
90
|
+
'x-forwarded-proto': req.socket.encrypted ? 'https' : 'http',
|
|
91
|
+
host: targetUrl.host
|
|
92
|
+
})
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const client = targetUrl.protocol === 'https:' ? https : http;
|
|
96
|
+
|
|
97
|
+
const proxyReq = client.request(options, (proxyRes) => {
|
|
98
|
+
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
|
99
|
+
proxyRes.pipe(res, { end: true });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
proxyReq.on('error', (err) => {
|
|
103
|
+
log(config, 'upstream error:', err.message);
|
|
104
|
+
if (!res.headersSent) {
|
|
105
|
+
res.writeHead(502, { 'Content-Type': 'text/plain' });
|
|
106
|
+
}
|
|
107
|
+
res.end('502 Bad Gateway');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
req.pipe(proxyReq, { end: true });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function makeRequestHandler(config) {
|
|
114
|
+
return function requestHandler(req, res) {
|
|
115
|
+
const route = matchRoute(config.routes, req.headers.host);
|
|
116
|
+
|
|
117
|
+
log(config, req.method, req.headers.host || '-', req.url, '->', route ? route.host : 'no-match');
|
|
118
|
+
|
|
119
|
+
if (!route) {
|
|
120
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
121
|
+
return res.end('404 Not Found (no matching route for this host)');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const target = pickTarget(route);
|
|
125
|
+
|
|
126
|
+
if (target) {
|
|
127
|
+
return proxyRequest(target, req, res, config);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (route.staticDir) {
|
|
131
|
+
return serveStatic(route.staticDir, req.url, res);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
res.writeHead(502, { 'Content-Type': 'text/plain' });
|
|
135
|
+
res.end('502 Bad Gateway (no target configured for this host)');
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function startServer(config) {
|
|
140
|
+
const handler = makeRequestHandler(config);
|
|
141
|
+
const servers = [];
|
|
142
|
+
|
|
143
|
+
const httpServer = http.createServer(handler);
|
|
144
|
+
httpServer.listen(config.port, () => {
|
|
145
|
+
console.log(`[nodeproxy] HTTP listening on port ${config.port}`);
|
|
146
|
+
});
|
|
147
|
+
servers.push(httpServer);
|
|
148
|
+
|
|
149
|
+
if (config.https && config.https.enabled) {
|
|
150
|
+
if (!config.https.key || !config.https.cert) {
|
|
151
|
+
console.warn('[nodeproxy] https.enabled is true but key/cert paths are missing — skipping HTTPS.');
|
|
152
|
+
} else {
|
|
153
|
+
const httpsOptions = {
|
|
154
|
+
key: fs.readFileSync(config.https.key),
|
|
155
|
+
cert: fs.readFileSync(config.https.cert)
|
|
156
|
+
};
|
|
157
|
+
const httpsServer = https.createServer(httpsOptions, handler);
|
|
158
|
+
httpsServer.listen(config.https.port, () => {
|
|
159
|
+
console.log(`[nodeproxy] HTTPS listening on port ${config.https.port}`);
|
|
160
|
+
});
|
|
161
|
+
servers.push(httpsServer);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return servers;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = { startServer, matchRoute, pickTarget };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"port": 8080,
|
|
3
|
+
"https": {
|
|
4
|
+
"enabled": false,
|
|
5
|
+
"port": 8443,
|
|
6
|
+
"key": "",
|
|
7
|
+
"cert": ""
|
|
8
|
+
},
|
|
9
|
+
"logging": true,
|
|
10
|
+
"routes": [
|
|
11
|
+
{
|
|
12
|
+
"host": "*",
|
|
13
|
+
"targets": [
|
|
14
|
+
"http://localhost:3000"
|
|
15
|
+
],
|
|
16
|
+
"staticDir": null
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"host": "https://hirenray.rest",
|
|
20
|
+
"targets": [
|
|
21
|
+
"http://localhost:3000"
|
|
22
|
+
],
|
|
23
|
+
"staticDir": null
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rexaray008/nodeproxy",
|
|
3
|
+
"publishConfig": {
|
|
4
|
+
"access": "public"
|
|
5
|
+
},
|
|
6
|
+
"version": "1.0.1",
|
|
7
|
+
"description": "A lightweight, zero-dependency reverse proxy / web server (like a mini nginx) for Node.js. One command to install, configure and run.",
|
|
8
|
+
"main": "lib/server.js",
|
|
9
|
+
"bin": {
|
|
10
|
+
"nodeproxy": "bin/cli.js"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"start": "node bin/cli.js start"
|
|
14
|
+
},
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=16"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"dependencies": {}
|
|
20
|
+
}
|