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 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
+ }
@@ -0,0 +1,4 @@
1
+ import { build } from "esbuild";
2
+
3
+ await build({ entryPoints: ["src/client/kli.js"], bundle: true, format: "iife", platform: "browser", outfile: "dist/kli.js", define: { "self.__KLINER_SERVICE_PATH__": JSON.stringify("/service/") } });
4
+ console.log("Built dist/kli.js");
@@ -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
+ });
@@ -0,0 +1,4 @@
1
+ export async function registerKliner({ script = "/kli.js", scope = "/service/" } = {}) {
2
+ if (!("serviceWorker" in navigator)) throw new Error("Service workers are not supported");
3
+ return navigator.serviceWorker.register(script, { scope });
4
+ }
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();
@@ -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
+ }