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 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
+ ```
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../dist/index.js';
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
+ }