kliner 0.1.0-beta
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/LICENSE +21 -0
- package/README.md +32 -0
- package/bin/cli.js +65 -0
- package/package.json +32 -0
- package/scripts/build.js +4 -0
- package/src/client/interceptor.js +7 -0
- package/src/client/kli.js +22 -0
- package/src/client/runtime.js +4 -0
- package/src/codec.js +43 -0
- package/src/config.js +45 -0
- package/src/proxy.js +51 -0
- package/src/server.js +36 -0
- package/src/ws-proxy.js +24 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 WalnutsForNerds Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Kliner
|
|
2
|
+
|
|
3
|
+
Developer-friendly service-worker web proxy framework for local development.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install
|
|
9
|
+
npm run build
|
|
10
|
+
npm start
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Open `http://localhost:8080/health`. The demo is available from `klinerdemo/` when served by your host app. Kliner is a framework, not an unrestricted public proxy: configure an allowlist before exposing it outside local development.
|
|
14
|
+
|
|
15
|
+
## Install into an existing project
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npx kliner install
|
|
19
|
+
npm run kliner:start
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Use `kliner init my-proxy` to generate a starter project and `kliner doctor` to diagnose an installation.
|
|
23
|
+
|
|
24
|
+
## Configuration
|
|
25
|
+
|
|
26
|
+
`kliner.config.js` supports `port`, `host`, `servicePath`, `websocketPath`, `allowlist`, `blocked`, `cors`, `requestTimeout`, `maxRequestSize`, `maxResponseSize`, and `logging.level`. Environment variables `KLINER_PORT`, `KLINER_HOST`, `KLINER_TARGET_ALLOWLIST`, and `KLINER_LOG_LEVEL` override the matching values.
|
|
27
|
+
|
|
28
|
+
URL encoding is reversible URL encoding, not encryption. Operators are responsible for access control, HTTPS, rate limiting, and safe deployment.
|
|
29
|
+
|
|
30
|
+
## License
|
|
31
|
+
|
|
32
|
+
MIT
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
import chalk from "chalk";
|
|
7
|
+
import { loadConfig } from "../src/config.js";
|
|
8
|
+
|
|
9
|
+
const version = "0.1.0-beta";
|
|
10
|
+
const runtimeFiles = ["src/server.js", "src/proxy.js", "src/codec.js", "src/ws-proxy.js", "src/config.js", "src/client/kli.js", "src/client/interceptor.js", "src/client/runtime.js", "scripts/build.js", "kliner.config.js"];
|
|
11
|
+
const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
|
|
12
|
+
|
|
13
|
+
function copyRuntime(destination) {
|
|
14
|
+
for (const file of runtimeFiles) {
|
|
15
|
+
const target = path.join(destination, file);
|
|
16
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
17
|
+
if (fs.existsSync(target)) { console.log(chalk.yellow(` conflict: ${file} (preserved)`)); continue; }
|
|
18
|
+
fs.copyFileSync(path.join(root, file), target);
|
|
19
|
+
console.log(chalk.green(` added: ${file}`));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function updatePackage(cwd) {
|
|
24
|
+
const file = path.join(cwd, "package.json");
|
|
25
|
+
const packageJson = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
26
|
+
packageJson.scripts = { ...packageJson.scripts, "kliner:start": "node src/server.js", "kliner:dev": "nodemon src/server.js", "kliner:build": "node scripts/build.js", "kliner:doctor": "kliner doctor" };
|
|
27
|
+
packageJson.dependencies = { ...packageJson.dependencies, express: "^4.19.0", undici: "^6.19.0", ws: "^8.18.0", cors: "^2.8.5", dotenv: "^16.4.0", commander: "^12.1.0", chalk: "^5.3.0" };
|
|
28
|
+
packageJson.devDependencies = { ...packageJson.devDependencies, esbuild: "^0.23.0", nodemon: "^3.1.0" };
|
|
29
|
+
fs.writeFileSync(file, `${JSON.stringify(packageJson, null, 2)}\n`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function install(cwd = process.cwd()) {
|
|
33
|
+
const packageFile = path.join(cwd, "package.json");
|
|
34
|
+
if (!fs.existsSync(packageFile)) { console.error(chalk.red("No package.json found. Use kliner init or run this in a Node project.")); process.exitCode = 1; return; }
|
|
35
|
+
if (fs.existsSync(path.join(cwd, "src/kliner")) || fs.existsSync(path.join(cwd, "kliner.config.js"))) { console.log(chalk.yellow("Kliner is already installed. Use kliner doctor or kliner update.")); return; }
|
|
36
|
+
console.log(chalk.bold(`KLINER v${version}\nDetecting project...`));
|
|
37
|
+
updatePackage(cwd); copyRuntime(cwd);
|
|
38
|
+
console.log(chalk.green("Kliner installation complete. Run npm install, then npm run kliner:start."));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function init(name) {
|
|
42
|
+
const destination = path.resolve(process.cwd(), name);
|
|
43
|
+
fs.mkdirSync(destination, { recursive: true });
|
|
44
|
+
fs.writeFileSync(path.join(destination, "package.json"), JSON.stringify({ name: path.basename(destination), version: "0.1.0", type: "module", scripts: { start: "node src/server.js", build: "node scripts/build.js" }, dependencies: { express: "^4.19.0", undici: "^6.19.0", ws: "^8.18.0", cors: "^2.8.5", dotenv: "^16.4.0" }, devDependencies: { esbuild: "^0.23.0", nodemon: "^3.1.0" } }, null, 2) + "\n");
|
|
45
|
+
copyRuntime(destination);
|
|
46
|
+
fs.mkdirSync(path.join(destination, "public"), { recursive: true });
|
|
47
|
+
fs.writeFileSync(path.join(destination, "public/index.html"), "<!doctype html><title>Kliner</title><script>navigator.serviceWorker?.register('/kli.js',{scope:'/service/'})</script><h1>Kliner</h1>\n");
|
|
48
|
+
console.log(chalk.green(`Created ${destination}`));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function doctor() {
|
|
52
|
+
const cwd = process.cwd(); const checks = [["package.json", fs.existsSync(path.join(cwd, "package.json"))], ["Kliner configuration", fs.existsSync(path.join(cwd, "kliner.config.js"))], ["Service worker", fs.existsSync(path.join(cwd, "src/client/kli.js"))], ["Codec", fs.existsSync(path.join(cwd, "src/codec.js"))]];
|
|
53
|
+
for (const [label, ok] of checks) console.log(`${ok ? chalk.green("✓") : chalk.red("✗")} ${label}`);
|
|
54
|
+
try { await loadConfig(cwd); console.log(chalk.green("✓ Configuration valid")); } catch { console.log(chalk.red("✗ Configuration invalid")); process.exitCode = 1; }
|
|
55
|
+
if (checks.some(([, ok]) => !ok)) process.exitCode = 1;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const program = new Command().name("kliner").version(version).description("Developer-friendly service-worker web proxy framework");
|
|
59
|
+
program.command("install").description("Install Kliner into the current project").action(() => install());
|
|
60
|
+
program.command("init <directory>").description("Create a Kliner starter project").action(init);
|
|
61
|
+
program.command("start").description("Start the proxy server").action(() => import("../src/server.js").then(({ startServer }) => startServer()));
|
|
62
|
+
program.command("dev").description("Start the proxy with nodemon").action(() => spawn("npx", ["nodemon", "src/server.js"], { stdio: "inherit", shell: true }));
|
|
63
|
+
program.command("build").description("Build the service worker").action(() => import("../scripts/build.js"));
|
|
64
|
+
program.command("doctor").description("Diagnose the current project").action(doctor);
|
|
65
|
+
program.parseAsync();
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kliner",
|
|
3
|
+
"version": "0.1.0-beta",
|
|
4
|
+
"description": "Developer-friendly service-worker web proxy framework",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "kliner": "bin/cli.js" },
|
|
7
|
+
"main": "./src/server.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/server.js",
|
|
10
|
+
"./codec": "./src/codec.js",
|
|
11
|
+
"./proxy": "./src/proxy.js"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"start": "node src/server.js",
|
|
15
|
+
"dev": "nodemon src/server.js",
|
|
16
|
+
"build": "node scripts/build.js",
|
|
17
|
+
"test": "node --test"
|
|
18
|
+
},
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"engines": { "node": ">=18.0.0" },
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"chalk": "^5.3.0",
|
|
23
|
+
"commander": "^12.1.0",
|
|
24
|
+
"cors": "^2.8.5",
|
|
25
|
+
"dotenv": "^16.4.0",
|
|
26
|
+
"express": "^4.19.0",
|
|
27
|
+
"undici": "^6.19.0",
|
|
28
|
+
"ws": "^8.18.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": { "esbuild": "^0.23.0", "nodemon": "^3.1.0" },
|
|
31
|
+
"files": ["bin", "src", "scripts", "templates", "LICENSE", "README.md"]
|
|
32
|
+
}
|
package/scripts/build.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export function installInterceptors({ servicePath = "/service/" } = {}) {
|
|
2
|
+
const originalFetch = globalThis.fetch;
|
|
3
|
+
if (originalFetch) globalThis.fetch = (input, init) => {
|
|
4
|
+
const target = new URL(typeof input === "string" ? input : input.url, globalThis.location?.href);
|
|
5
|
+
return originalFetch(`${servicePath}${target.href}`, init);
|
|
6
|
+
};
|
|
7
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const SERVICE_PREFIX = self.__KLINER_SERVICE_PATH__ || "/service/";
|
|
2
|
+
|
|
3
|
+
function encodedTarget(url) {
|
|
4
|
+
const bytes = new TextEncoder().encode(new URL(url).href);
|
|
5
|
+
let binary = "";
|
|
6
|
+
bytes.forEach((byte) => { binary += String.fromCharCode(byte); });
|
|
7
|
+
return `k1.${btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "")}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
self.addEventListener("install", () => self.skipWaiting());
|
|
11
|
+
self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()));
|
|
12
|
+
self.addEventListener("fetch", (event) => {
|
|
13
|
+
const requestUrl = new URL(event.request.url);
|
|
14
|
+
if (requestUrl.origin === self.location.origin && requestUrl.pathname.startsWith(SERVICE_PREFIX)) return;
|
|
15
|
+
if (!["http:", "https:"].includes(requestUrl.protocol)) return;
|
|
16
|
+
event.respondWith(fetch(`${SERVICE_PREFIX}${encodedTarget(requestUrl.href)}`, {
|
|
17
|
+
method: event.request.method,
|
|
18
|
+
headers: event.request.headers,
|
|
19
|
+
body: ["GET", "HEAD"].includes(event.request.method) ? undefined : event.request.body,
|
|
20
|
+
redirect: "follow"
|
|
21
|
+
}));
|
|
22
|
+
});
|
package/src/codec.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const PREFIX = "k1.";
|
|
2
|
+
|
|
3
|
+
function toBase64Url(value) {
|
|
4
|
+
return Buffer.from(value, "utf8").toString("base64url");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function fromBase64Url(value) {
|
|
8
|
+
return Buffer.from(value, "base64url").toString("utf8");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function encodeKlinerUrl(url) {
|
|
12
|
+
let parsed;
|
|
13
|
+
try {
|
|
14
|
+
parsed = new URL(url);
|
|
15
|
+
} catch {
|
|
16
|
+
const error = new Error("Invalid target URL");
|
|
17
|
+
error.code = "KLINER_INVALID_URL";
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
21
|
+
const error = new Error("Only HTTP and HTTPS targets are supported");
|
|
22
|
+
error.code = "KLINER_INVALID_URL";
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
return `${PREFIX}${toBase64Url(parsed.href)}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function decodeKlinerUrl(encoded) {
|
|
29
|
+
if (typeof encoded !== "string" || !encoded.startsWith(PREFIX)) {
|
|
30
|
+
const error = new Error("Invalid Kliner URL encoding");
|
|
31
|
+
error.code = "KLINER_CODEC_ERROR";
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const url = new URL(fromBase64Url(encoded.slice(PREFIX.length)));
|
|
36
|
+
if (!["http:", "https:"].includes(url.protocol)) throw new Error();
|
|
37
|
+
return url.href;
|
|
38
|
+
} catch {
|
|
39
|
+
const error = new Error("Invalid Kliner URL encoding");
|
|
40
|
+
error.code = "KLINER_CODEC_ERROR";
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import dotenv from "dotenv";
|
|
4
|
+
|
|
5
|
+
dotenv.config();
|
|
6
|
+
|
|
7
|
+
const defaults = {
|
|
8
|
+
port: 8080,
|
|
9
|
+
host: "localhost",
|
|
10
|
+
servicePath: "/service/",
|
|
11
|
+
websocketPath: "/service/ws",
|
|
12
|
+
allowlist: [],
|
|
13
|
+
blocked: [],
|
|
14
|
+
cors: { origin: "http://localhost:8080" },
|
|
15
|
+
requestTimeout: 30000,
|
|
16
|
+
maxRequestSize: 10 * 1024 * 1024,
|
|
17
|
+
maxResponseSize: 100 * 1024 * 1024,
|
|
18
|
+
logging: { level: "info" }
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export async function loadConfig(cwd = process.cwd()) {
|
|
22
|
+
const configPath = path.join(cwd, "kliner.config.js");
|
|
23
|
+
let project = {};
|
|
24
|
+
if (fs.existsSync(configPath)) project = (await import(`${configPath}?t=${Date.now()}`)).default ?? {};
|
|
25
|
+
const envList = process.env.KLINER_TARGET_ALLOWLIST?.split(",").map((item) => item.trim()).filter(Boolean);
|
|
26
|
+
return {
|
|
27
|
+
...defaults,
|
|
28
|
+
...project,
|
|
29
|
+
port: Number(process.env.KLINER_PORT ?? project.port ?? defaults.port),
|
|
30
|
+
host: process.env.KLINER_HOST ?? project.host ?? defaults.host,
|
|
31
|
+
allowlist: envList ?? project.allowlist ?? defaults.allowlist,
|
|
32
|
+
logging: { ...defaults.logging, ...(project.logging ?? {}), level: process.env.KLINER_LOG_LEVEL ?? project.logging?.level ?? defaults.logging.level }
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function matchesTarget(target, patterns = []) {
|
|
37
|
+
return patterns.some((pattern) => {
|
|
38
|
+
try { return new RegExp(pattern).test(target.hostname); } catch { return target.hostname === pattern || target.host === pattern; }
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isAllowed(target, config) {
|
|
43
|
+
if (matchesTarget(target, config.blocked)) return false;
|
|
44
|
+
return config.allowlist.length === 0 || matchesTarget(target, config.allowlist);
|
|
45
|
+
}
|
package/src/proxy.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { request } from "undici";
|
|
2
|
+
import { decodeKlinerUrl, encodeKlinerUrl } from "./codec.js";
|
|
3
|
+
import { isAllowed } from "./config.js";
|
|
4
|
+
|
|
5
|
+
const hopByHop = new Set(["connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade"]);
|
|
6
|
+
|
|
7
|
+
export function rewriteResponse(text, baseUrl, contentType) {
|
|
8
|
+
if (/text\/html/i.test(contentType)) {
|
|
9
|
+
return text.replace(/(\b(?:href|src|action|poster)\s*=\s*["'])([^"']+)(["'])/gi, (match, start, value, end) => {
|
|
10
|
+
if (/^(?:data:|javascript:|mailto:|#)/i.test(value)) return match;
|
|
11
|
+
return `${start}${new URL(value, baseUrl).href}${end}`;
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
if (/text\/css/i.test(contentType)) return text.replace(/url\(\s*(["']?)([^)"']+)\1\s*\)/gi, (match, quote, value) => `url(${quote}${new URL(value, baseUrl).href}${quote})`);
|
|
15
|
+
return text;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function proxyRequest(req, res, config, logger = () => {}) {
|
|
19
|
+
let target;
|
|
20
|
+
try { target = new URL(decodeKlinerUrl(req.params[0] || req.params.encoded)); } catch (error) {
|
|
21
|
+
res.status(400).json({ error: error.code ?? "KLINER_CODEC_ERROR", message: error.message }); return;
|
|
22
|
+
}
|
|
23
|
+
if (!isAllowed(target, config)) { res.status(403).json({ error: "KLINER_TARGET_BLOCKED" }); return; }
|
|
24
|
+
const controller = new AbortController();
|
|
25
|
+
const timeout = setTimeout(() => controller.abort(), config.requestTimeout);
|
|
26
|
+
try {
|
|
27
|
+
const headers = { ...req.headers };
|
|
28
|
+
delete headers.host; delete headers.connection; delete headers["content-length"];
|
|
29
|
+
const upstream = await request(target, { method: req.method, headers, body: ["GET", "HEAD"].includes(req.method) ? undefined : req, signal: controller.signal, maxRedirections: 5 });
|
|
30
|
+
logger(req.method, target.href, upstream.statusCode, upstream.headers["content-type"]);
|
|
31
|
+
for (const [name, value] of Object.entries(upstream.headers)) {
|
|
32
|
+
if (hopByHop.has(name) || name === "content-length" || name === "content-encoding") continue;
|
|
33
|
+
if (name === "location") {
|
|
34
|
+
const redirect = new URL(value, target);
|
|
35
|
+
if (isAllowed(redirect, config)) res.setHeader(name, `${config.servicePath}${encodeKlinerUrl(redirect.href)}`);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
res.setHeader(name, value);
|
|
39
|
+
}
|
|
40
|
+
res.status(upstream.statusCode);
|
|
41
|
+
const type = String(upstream.headers["content-type"] ?? "");
|
|
42
|
+
if (/text\/html|text\/css/i.test(type)) {
|
|
43
|
+
const body = await upstream.body.text();
|
|
44
|
+
if (Buffer.byteLength(body) > config.maxResponseSize) throw Object.assign(new Error("Response too large"), { code: "KLINER_RESPONSE_TOO_LARGE" });
|
|
45
|
+
res.send(rewriteResponse(body, target.href, type));
|
|
46
|
+
} else upstream.body.pipe(res);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (res.headersSent) res.destroy(error);
|
|
49
|
+
else res.status(error.name === "AbortError" ? 504 : 502).json({ error: error.name === "AbortError" ? "KLINER_TIMEOUT" : "KLINER_TARGET_UNREACHABLE" });
|
|
50
|
+
} finally { clearTimeout(timeout); }
|
|
51
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import express from "express";
|
|
3
|
+
import cors from "cors";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { loadConfig } from "./config.js";
|
|
7
|
+
import { proxyRequest } from "./proxy.js";
|
|
8
|
+
import { attachWebSocketProxy } from "./ws-proxy.js";
|
|
9
|
+
|
|
10
|
+
export async function createApp(config) {
|
|
11
|
+
config ??= await loadConfig();
|
|
12
|
+
const app = express();
|
|
13
|
+
app.use(cors(config.cors));
|
|
14
|
+
app.get("/health", (_req, res) => res.json({ status: "ok", name: "kliner", version: "0.1.0-beta" }));
|
|
15
|
+
app.get("/status", (_req, res) => res.json({ status: "ok", allowlist: config.allowlist, servicePath: config.servicePath }));
|
|
16
|
+
const clientPath = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
app.get("/kli.js", (_req, res) => res.sendFile(path.join(clientPath, "client", "kli.js")));
|
|
18
|
+
app.all(`${config.servicePath}:encoded(*)`, (req, res) => proxyRequest(req, res, config, (method, target, status, type) => {
|
|
19
|
+
if (config.logging.level !== "silent") console.log(`[${new Date().toISOString().slice(11, 19)}] ${method} ${target} ${status} ${type ?? ""}`);
|
|
20
|
+
}));
|
|
21
|
+
app.use((error, _req, res, _next) => res.status(500).json({ error: "KLINER_CONFIGURATION_ERROR", message: config.logging.level === "debug" ? error.message : "Kliner request failed" }));
|
|
22
|
+
return app;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function startServer(config) {
|
|
26
|
+
config ??= await loadConfig();
|
|
27
|
+
const app = await createApp(config);
|
|
28
|
+
const server = http.createServer(app);
|
|
29
|
+
attachWebSocketProxy(server, config);
|
|
30
|
+
return new Promise((resolve) => server.listen(config.port, config.host, () => {
|
|
31
|
+
console.log(`Kliner proxy running at http://${config.host}:${config.port}`);
|
|
32
|
+
resolve(server);
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) startServer();
|
package/src/ws-proxy.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
2
|
+
import { decodeKlinerUrl } from "./codec.js";
|
|
3
|
+
import { isAllowed } from "./config.js";
|
|
4
|
+
|
|
5
|
+
export function attachWebSocketProxy(server, config, path = config.websocketPath) {
|
|
6
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
7
|
+
server.on("upgrade", (request, socket, head) => {
|
|
8
|
+
if (!request.url.startsWith(path)) return;
|
|
9
|
+
const encoded = new URL(request.url, "http://localhost").searchParams.get("url");
|
|
10
|
+
let target;
|
|
11
|
+
try { target = new URL(decodeKlinerUrl(encoded)); } catch { socket.destroy(); return; }
|
|
12
|
+
if (!isAllowed(target, config) || !["http:", "https:"].includes(target.protocol)) { socket.destroy(); return; }
|
|
13
|
+
wss.handleUpgrade(request, socket, head, (client) => {
|
|
14
|
+
const upstream = new WebSocket(target.href, { headers: { origin: target.origin } });
|
|
15
|
+
client.on("message", (data, isBinary) => { if (upstream.readyState === WebSocket.OPEN) upstream.send(data, { binary: isBinary }); });
|
|
16
|
+
upstream.on("open", () => client.emit("open"));
|
|
17
|
+
upstream.on("message", (data, isBinary) => { if (client.readyState === WebSocket.OPEN) client.send(data, { binary: isBinary }); });
|
|
18
|
+
const close = () => { client.close(); upstream.close(); };
|
|
19
|
+
client.on("close", close); upstream.on("close", close);
|
|
20
|
+
client.on("error", close); upstream.on("error", close);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
return wss;
|
|
24
|
+
}
|