locagate 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 +81 -0
- package/bin/locagate.js +2 -0
- package/dist/config.js +55 -0
- package/dist/index.js +182 -0
- package/dist/jwt.js +55 -0
- package/dist/router.js +23 -0
- package/dist/types.js +1 -0
- package/locagate.yaml.sample +28 -0
- package/package.json +34 -0
package/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# locagate
|
|
2
|
+
|
|
3
|
+
Tiny, file-configurable local API gateway. YAML config, prefix path routing, optional JWT. One command, zero setup.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx locagate ./config.yaml
|
|
7
|
+
pnpm dlx locagate ./config.yaml
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
Create a `locagate.yaml`:
|
|
15
|
+
|
|
16
|
+
```yaml
|
|
17
|
+
listen: "0.0.0.0:5000"
|
|
18
|
+
jwt:
|
|
19
|
+
enabled: true
|
|
20
|
+
secret: "$JWT_SECRET"
|
|
21
|
+
passthrough: false
|
|
22
|
+
routes:
|
|
23
|
+
- match: "/v1/core/*"
|
|
24
|
+
target: "http://localhost:3000"
|
|
25
|
+
- match: "/*"
|
|
26
|
+
target: "http://localhost:3003"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Run it:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npx locagate ./locagate.yaml
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Or install globally:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npm install -g locagate
|
|
39
|
+
locagate ./locagate.yaml
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Configuration reference
|
|
43
|
+
|
|
44
|
+
| Key | Type | Default | Description |
|
|
45
|
+
|-----|------|---------|-------------|
|
|
46
|
+
| `listen` | string | `"0.0.0.0:5000"` | Address + port to bind |
|
|
47
|
+
| `jwt.enabled` | bool | `false` | Enable JWT verification |
|
|
48
|
+
| `jwt.secret` | string | — | HMAC secret (`$VAR` / `${VAR:-default}` expands env vars) |
|
|
49
|
+
| `jwt.passthrough` | bool | `false` | Allow unauthenticated requests through |
|
|
50
|
+
| `jwt.exclude` | string[] | `[]` | Path prefixes that skip JWT |
|
|
51
|
+
| `routes` | array | — | Route table (first match wins) |
|
|
52
|
+
|
|
53
|
+
**Environment variable expansion** works in `jwt.secret` and route `target` fields.
|
|
54
|
+
|
|
55
|
+
### Route matching
|
|
56
|
+
|
|
57
|
+
| Pattern | Matches |
|
|
58
|
+
|---------|---------|
|
|
59
|
+
| `/*` | All paths (catch-all) |
|
|
60
|
+
| `/v1/core/*` | `/v1/core/...` and `/v1/core` |
|
|
61
|
+
| `/health` | Only `/health` |
|
|
62
|
+
|
|
63
|
+
## How it works
|
|
64
|
+
|
|
65
|
+
1. Reads your YAML config (first argument, or `./locagate.yaml`)
|
|
66
|
+
2. Expands `$VAR` / `${VAR:-default}` from environment variables
|
|
67
|
+
3. For each incoming request, walks the route table top-to-bottom
|
|
68
|
+
4. First match wins — proxies to `target` via Node's `http.request`
|
|
69
|
+
5. WebSocket upgrade requests (e.g., Next.js HMR) are proxied transparently
|
|
70
|
+
6. JWT verification via HS256, `x-gateway-user` header injected on success
|
|
71
|
+
|
|
72
|
+
## Files
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
bin/locagate.js — CLI entrypoint (what npx runs)
|
|
76
|
+
src/index.ts — HTTP server, routing, proxying, JWT check
|
|
77
|
+
src/config.ts — YAML/JSON config loading with $VAR expansion
|
|
78
|
+
src/router.ts — prefix-based route matching
|
|
79
|
+
src/jwt.ts — HS256 JWT verification
|
|
80
|
+
locagate.yaml.sample — annotated example config
|
|
81
|
+
```
|
package/bin/locagate.js
ADDED
package/dist/config.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { accessSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { load as parseYaml } from 'js-yaml';
|
|
5
|
+
// ponytail: $VAR and ${VAR:-default} expansion; shallow — no recursion
|
|
6
|
+
function expandEnv(value) {
|
|
7
|
+
return value.replace(/\$([A-Z0-9_]+)|\$\{([A-Z0-9_]+):-([^}]*)\}/g, (_m, simple, braced, defaultVal) => {
|
|
8
|
+
const name = simple ?? braced;
|
|
9
|
+
return process.env[name] ?? defaultVal ?? '';
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
function normalizeConfig(raw) {
|
|
13
|
+
return {
|
|
14
|
+
listen: raw.listen ?? '0.0.0.0:5000',
|
|
15
|
+
jwt: {
|
|
16
|
+
enabled: raw.jwt?.enabled ?? false,
|
|
17
|
+
secret: raw.jwt?.secret ? expandEnv(raw.jwt.secret) : undefined,
|
|
18
|
+
passthrough: raw.jwt?.passthrough ?? false,
|
|
19
|
+
exclude: raw.jwt?.exclude ?? []
|
|
20
|
+
},
|
|
21
|
+
routes: (raw.routes ?? []).map((route) => ({
|
|
22
|
+
match: route.match,
|
|
23
|
+
target: expandEnv(route.target),
|
|
24
|
+
jwt: route.jwt
|
|
25
|
+
}))
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const CONFIG_FILES = ['locagate.yaml', 'locagate.yml', 'locagate.json'];
|
|
29
|
+
export async function loadConfig(configPath) {
|
|
30
|
+
const fileName = configPath ?? findConfigFile();
|
|
31
|
+
const content = await readFile(fileName, 'utf8');
|
|
32
|
+
const ext = path.extname(fileName).toLowerCase();
|
|
33
|
+
const parsed = ext === '.json' ? JSON.parse(content) : parseYaml(content);
|
|
34
|
+
return normalizeConfig(parsed);
|
|
35
|
+
}
|
|
36
|
+
function findConfigFile() {
|
|
37
|
+
for (const name of CONFIG_FILES) {
|
|
38
|
+
const p = path.resolve(process.cwd(), name);
|
|
39
|
+
try {
|
|
40
|
+
accessSync(p);
|
|
41
|
+
return p;
|
|
42
|
+
}
|
|
43
|
+
catch { /* try next */ }
|
|
44
|
+
}
|
|
45
|
+
return path.resolve(process.cwd(), 'locagate.yaml');
|
|
46
|
+
}
|
|
47
|
+
export function resolveConfigPath(input) {
|
|
48
|
+
if (!input) {
|
|
49
|
+
return findConfigFile();
|
|
50
|
+
}
|
|
51
|
+
if (path.isAbsolute(input)) {
|
|
52
|
+
return input;
|
|
53
|
+
}
|
|
54
|
+
return path.resolve(process.cwd(), input);
|
|
55
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import { loadConfig, resolveConfigPath } from './config.js';
|
|
4
|
+
import { verifyJwt } from './jwt.js';
|
|
5
|
+
import { matchRoute } from './router.js';
|
|
6
|
+
function parseListenAddress(value) {
|
|
7
|
+
const trimmed = value.trim();
|
|
8
|
+
if (!trimmed) {
|
|
9
|
+
return { host: '127.0.0.1', port: 5000 };
|
|
10
|
+
}
|
|
11
|
+
if (/^:\d+$/.test(trimmed)) {
|
|
12
|
+
return { host: '127.0.0.1', port: Number(trimmed.slice(1)) };
|
|
13
|
+
}
|
|
14
|
+
const lastColon = trimmed.lastIndexOf(':');
|
|
15
|
+
if (lastColon === -1) {
|
|
16
|
+
return { host: '127.0.0.1', port: Number(trimmed) };
|
|
17
|
+
}
|
|
18
|
+
// ponytail: keep 0.0.0.0 as-is — needed for Docker port forwarding
|
|
19
|
+
const host = trimmed.slice(0, lastColon) || '127.0.0.1';
|
|
20
|
+
const port = Number(trimmed.slice(lastColon + 1));
|
|
21
|
+
return { host, port };
|
|
22
|
+
}
|
|
23
|
+
function getAuthHeader(req) {
|
|
24
|
+
const header = req.headers.authorization;
|
|
25
|
+
if (!header)
|
|
26
|
+
return undefined;
|
|
27
|
+
return Array.isArray(header) ? header[0] : header;
|
|
28
|
+
}
|
|
29
|
+
function shouldBypassJwt(pathname, jwtConfig, routeJwtOverride) {
|
|
30
|
+
if (!jwtConfig?.enabled)
|
|
31
|
+
return true;
|
|
32
|
+
if (routeJwtOverride === false)
|
|
33
|
+
return true;
|
|
34
|
+
if (routeJwtOverride === true)
|
|
35
|
+
return false;
|
|
36
|
+
if (jwtConfig.exclude?.some((entry) => entry === pathname || pathname.startsWith(entry.replace(/\*$/, '')))) {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
async function proxyRequest(req, res, target) {
|
|
42
|
+
const headers = {};
|
|
43
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
44
|
+
if (value === undefined)
|
|
45
|
+
continue;
|
|
46
|
+
const resolvedValue = Array.isArray(value) ? value.join(',') : value ?? '';
|
|
47
|
+
headers[key] = resolvedValue;
|
|
48
|
+
}
|
|
49
|
+
headers.host = target.host;
|
|
50
|
+
headers['x-forwarded-host'] = req.headers.host ?? 'localhost';
|
|
51
|
+
headers['x-forwarded-proto'] = 'http';
|
|
52
|
+
headers['x-forwarded-for'] = req.socket.remoteAddress ?? '127.0.0.1';
|
|
53
|
+
const incomingUrl = new URL(req.url ?? '/', 'http://localhost');
|
|
54
|
+
const targetPathname = `${target.pathname === '/' ? '' : target.pathname}${incomingUrl.pathname}`;
|
|
55
|
+
const targetPath = `${targetPathname === '' ? '/' : targetPathname}${incomingUrl.search}`;
|
|
56
|
+
const targetReq = http.request({
|
|
57
|
+
protocol: target.protocol,
|
|
58
|
+
hostname: target.hostname,
|
|
59
|
+
port: target.port,
|
|
60
|
+
path: targetPath,
|
|
61
|
+
method: req.method,
|
|
62
|
+
headers
|
|
63
|
+
}, (targetRes) => {
|
|
64
|
+
res.writeHead(targetRes.statusCode ?? 502, targetRes.headers);
|
|
65
|
+
targetRes.pipe(res);
|
|
66
|
+
});
|
|
67
|
+
targetReq.on('error', (error) => {
|
|
68
|
+
if (!res.headersSent) {
|
|
69
|
+
res.writeHead(502, { 'content-type': 'application/json' });
|
|
70
|
+
}
|
|
71
|
+
res.end(JSON.stringify({ error: 'Upstream connection failed', details: error.message }));
|
|
72
|
+
});
|
|
73
|
+
req.pipe(targetReq);
|
|
74
|
+
}
|
|
75
|
+
// ponytail: WebSocket upgrade proxy — no JWT check (HMR / dev connections are unauthenticated).
|
|
76
|
+
// Add JWT if production WebSockets need it.
|
|
77
|
+
function proxyUpgrade(req, socket, head, routes) {
|
|
78
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
79
|
+
const route = matchRoute(url.pathname, routes);
|
|
80
|
+
if (!route) {
|
|
81
|
+
socket.destroy();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const targetUrl = new URL(route.target);
|
|
85
|
+
const incomingUrl = new URL(req.url ?? '/', 'http://localhost');
|
|
86
|
+
const targetPathname = `${targetUrl.pathname === '/' ? '' : targetUrl.pathname}${incomingUrl.pathname}`;
|
|
87
|
+
const targetPath = `${targetPathname === '' ? '/' : targetPathname}${incomingUrl.search}`;
|
|
88
|
+
const headers = {};
|
|
89
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
90
|
+
if (value === undefined)
|
|
91
|
+
continue;
|
|
92
|
+
headers[key] = Array.isArray(value) ? value.join(',') : value;
|
|
93
|
+
}
|
|
94
|
+
headers.host = targetUrl.host;
|
|
95
|
+
const proxyReq = http.request({
|
|
96
|
+
hostname: targetUrl.hostname,
|
|
97
|
+
port: targetUrl.port,
|
|
98
|
+
path: targetPath,
|
|
99
|
+
method: req.method ?? 'GET',
|
|
100
|
+
headers,
|
|
101
|
+
});
|
|
102
|
+
proxyReq.on('upgrade', (proxyRes, proxySocket) => {
|
|
103
|
+
// Forward 101 response headers back to the client
|
|
104
|
+
const rawHeaders = Object.entries(proxyRes.headers)
|
|
105
|
+
.map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(', ') : v}`)
|
|
106
|
+
.join('\r\n');
|
|
107
|
+
socket.write('HTTP/1.1 101 Switching Protocols\r\n' + rawHeaders + '\r\n\r\n');
|
|
108
|
+
// Forward the initial head chunk (if any), then pipe bidirectionally
|
|
109
|
+
if (head?.length)
|
|
110
|
+
proxySocket.write(head);
|
|
111
|
+
proxySocket.pipe(socket);
|
|
112
|
+
socket.pipe(proxySocket);
|
|
113
|
+
});
|
|
114
|
+
// ponytail: if target responds without upgrading (non-101), close the socket
|
|
115
|
+
proxyReq.on('response', () => socket.destroy());
|
|
116
|
+
proxyReq.on('error', () => socket.destroy());
|
|
117
|
+
proxyReq.end();
|
|
118
|
+
}
|
|
119
|
+
async function main() {
|
|
120
|
+
const configPath = process.argv[2];
|
|
121
|
+
const resolvedPath = resolveConfigPath(configPath);
|
|
122
|
+
console.error('config:', resolvedPath);
|
|
123
|
+
const gatewayConfig = await loadConfig(resolvedPath);
|
|
124
|
+
console.error('routes:', JSON.stringify(gatewayConfig.routes.map(r => ({ match: r.match, target: r.target }))));
|
|
125
|
+
const { host, port } = parseListenAddress(gatewayConfig.listen);
|
|
126
|
+
console.error('listen:', host, port);
|
|
127
|
+
const server = http.createServer(async (req, res) => {
|
|
128
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
129
|
+
const route = matchRoute(url.pathname, gatewayConfig.routes);
|
|
130
|
+
if (!route) {
|
|
131
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
132
|
+
res.end(JSON.stringify({ error: 'No matching route' }));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const authHeader = getAuthHeader(req);
|
|
136
|
+
const jwtConfig = gatewayConfig.jwt;
|
|
137
|
+
const routeJwtOverride = route.jwt;
|
|
138
|
+
const shouldCheckJwt = !shouldBypassJwt(url.pathname, jwtConfig, routeJwtOverride);
|
|
139
|
+
if (jwtConfig?.enabled && shouldCheckJwt) {
|
|
140
|
+
if (!authHeader) {
|
|
141
|
+
if (jwtConfig.passthrough) {
|
|
142
|
+
// allow unauthenticated request when passthrough is enabled
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
res.writeHead(401, { 'content-type': 'application/json' });
|
|
146
|
+
res.end(JSON.stringify({ error: 'Missing bearer token' }));
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
const [scheme, token] = authHeader.split(' ');
|
|
152
|
+
if (scheme?.toLowerCase() !== 'bearer' || !token) {
|
|
153
|
+
res.writeHead(401, { 'content-type': 'application/json' });
|
|
154
|
+
res.end(JSON.stringify({ error: 'Invalid authorization header' }));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const verification = verifyJwt(token, jwtConfig);
|
|
158
|
+
if (!verification.ok) {
|
|
159
|
+
res.writeHead(401, { 'content-type': 'application/json' });
|
|
160
|
+
res.end(JSON.stringify({ error: verification.reason ?? 'Invalid JWT' }));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const payload = verification.payload;
|
|
164
|
+
res.setHeader('x-gateway-user', payload?.sub ?? 'unknown');
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const targetUrl = new URL(route.target);
|
|
168
|
+
await proxyRequest(req, res, targetUrl);
|
|
169
|
+
});
|
|
170
|
+
// ponytail: upgrade handler for Next.js HMR WebSocket — no JWT validation
|
|
171
|
+
server.on('upgrade', (req, socket, head) => {
|
|
172
|
+
// @ts-ignore
|
|
173
|
+
proxyUpgrade(req, socket, head, gatewayConfig.routes);
|
|
174
|
+
});
|
|
175
|
+
server.listen(port, host, () => {
|
|
176
|
+
console.log(`locagate listening on http://${host}:${port}`);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
main().catch((error) => {
|
|
180
|
+
console.error(error);
|
|
181
|
+
process.exit(1);
|
|
182
|
+
});
|
package/dist/jwt.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
function base64UrlDecode(value) {
|
|
3
|
+
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
|
4
|
+
const pad = normalized.length % 4;
|
|
5
|
+
const padded = pad ? normalized + '='.repeat(4 - pad) : normalized;
|
|
6
|
+
return Buffer.from(padded, 'base64');
|
|
7
|
+
}
|
|
8
|
+
function parseJwt(token) {
|
|
9
|
+
const parts = token.split('.');
|
|
10
|
+
if (parts.length !== 3)
|
|
11
|
+
return null;
|
|
12
|
+
try {
|
|
13
|
+
const header = JSON.parse(base64UrlDecode(parts[0]).toString('utf8'));
|
|
14
|
+
const payload = JSON.parse(base64UrlDecode(parts[1]).toString('utf8'));
|
|
15
|
+
return { header, payload };
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function verifySignature(token, secret, algorithm) {
|
|
22
|
+
const [headerB64, payloadB64, signatureB64] = token.split('.');
|
|
23
|
+
if (!headerB64 || !payloadB64 || !signatureB64)
|
|
24
|
+
return false;
|
|
25
|
+
const signingInput = `${headerB64}.${payloadB64}`;
|
|
26
|
+
const expected = createHmac('sha256', secret).update(signingInput).digest();
|
|
27
|
+
const actual = Buffer.from(signatureB64.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
|
|
28
|
+
if (actual.length !== expected.length)
|
|
29
|
+
return false;
|
|
30
|
+
return timingSafeEqual(expected, actual);
|
|
31
|
+
}
|
|
32
|
+
export function verifyJwt(token, jwtConfig) {
|
|
33
|
+
if (!jwtConfig.enabled || !jwtConfig.secret) {
|
|
34
|
+
return { ok: false, reason: 'jwt-disabled' };
|
|
35
|
+
}
|
|
36
|
+
const parsed = parseJwt(token);
|
|
37
|
+
if (!parsed) {
|
|
38
|
+
return { ok: false, reason: 'invalid-token' };
|
|
39
|
+
}
|
|
40
|
+
const { header, payload } = parsed;
|
|
41
|
+
if (!header || !payload) {
|
|
42
|
+
return { ok: false, reason: 'invalid-token' };
|
|
43
|
+
}
|
|
44
|
+
if (header.alg !== 'HS256') {
|
|
45
|
+
return { ok: false, reason: 'unsupported-algorithm' };
|
|
46
|
+
}
|
|
47
|
+
if (!verifySignature(token, jwtConfig.secret, header.alg)) {
|
|
48
|
+
return { ok: false, reason: 'signature-mismatch' };
|
|
49
|
+
}
|
|
50
|
+
const now = Math.floor(Date.now() / 1000);
|
|
51
|
+
if (payload.exp && payload.exp < now) {
|
|
52
|
+
return { ok: false, reason: 'token-expired' };
|
|
53
|
+
}
|
|
54
|
+
return { ok: true, payload };
|
|
55
|
+
}
|
package/dist/router.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
function normalizePattern(pattern) {
|
|
2
|
+
if (pattern === '*')
|
|
3
|
+
return '*';
|
|
4
|
+
if (pattern.endsWith('/*'))
|
|
5
|
+
return pattern.slice(0, -2);
|
|
6
|
+
return pattern;
|
|
7
|
+
}
|
|
8
|
+
export function matchRoute(pathname, routes) {
|
|
9
|
+
for (const route of routes) {
|
|
10
|
+
const match = route.match;
|
|
11
|
+
if (match === '*')
|
|
12
|
+
return route;
|
|
13
|
+
if (match.endsWith('/*')) {
|
|
14
|
+
const prefix = normalizePattern(match);
|
|
15
|
+
if (pathname === prefix || pathname.startsWith(prefix + '/'))
|
|
16
|
+
return route;
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (match.startsWith('/') && match === pathname)
|
|
20
|
+
return route;
|
|
21
|
+
}
|
|
22
|
+
return routes.find((route) => route.match === '/') || undefined;
|
|
23
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# locagate configuration — YAML
|
|
2
|
+
# Point to this file with `node src/index.ts /path/to/locagate.yaml`
|
|
3
|
+
# or place it in the working directory as locagate.yaml / locagate.yml.
|
|
4
|
+
|
|
5
|
+
listen: "0.0.0.0:5000"
|
|
6
|
+
|
|
7
|
+
# Optional JWT authentication
|
|
8
|
+
jwt:
|
|
9
|
+
enabled: true
|
|
10
|
+
secret: "$JWT_SECRET" # $VAR references are expanded from the environment
|
|
11
|
+
passthrough: false # true = allow unauthenticated requests through
|
|
12
|
+
exclude:
|
|
13
|
+
- /health
|
|
14
|
+
- /
|
|
15
|
+
- /v1/identity/login
|
|
16
|
+
- /v1/identity/register
|
|
17
|
+
|
|
18
|
+
# Route table — first match wins
|
|
19
|
+
# TARGET_HOST defaults to localhost; set to another host to route elsewhere
|
|
20
|
+
routes:
|
|
21
|
+
- match: "/v1/core/*"
|
|
22
|
+
target: "http://${TARGET_HOST:-localhost}:3000"
|
|
23
|
+
- match: "/v1/identity/*"
|
|
24
|
+
target: "http://${TARGET_HOST:-localhost}:3001"
|
|
25
|
+
- match: "/v1/notification/*"
|
|
26
|
+
target: "http://${TARGET_HOST:-localhost}:3002"
|
|
27
|
+
- match: "/*"
|
|
28
|
+
target: "http://${TARGET_HOST:-localhost}:3003"
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "locagate",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Tiny, file-configurable local API gateway — YAML config, prefix routing, optional JWT. Run with one command.",
|
|
6
|
+
"bin": {
|
|
7
|
+
"locagate": "./bin/locagate.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"dev": "tsx src/index.ts",
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"start": "node dist/index.js",
|
|
13
|
+
"typecheck": "tsc --noEmit",
|
|
14
|
+
"prepublishOnly": "npm run build"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"bin/",
|
|
18
|
+
"dist/",
|
|
19
|
+
"locagate.yaml.sample"
|
|
20
|
+
],
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/js-yaml": "^4.0.9",
|
|
23
|
+
"@types/node": "^22.10.1",
|
|
24
|
+
"tsx": "^4.23.0",
|
|
25
|
+
"typescript": "^6.0.3"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"dotenv": "^17.4.2",
|
|
29
|
+
"js-yaml": "^4.1.0"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=22.0.0"
|
|
33
|
+
}
|
|
34
|
+
}
|