@nivalos/lithium.js 1.1.0

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.
@@ -0,0 +1,359 @@
1
+ // lithium client
2
+ // Load with <script type="module"> (or import it). It pulls in whatever
3
+ // proxy scripts it needs by itself, no extra <script> tags required.
4
+ //
5
+ // import { init_lithium, navigate, back, on, config } from "/client/index.js"
6
+ // await init_lithium({ searchEngine: "google" })
7
+ // on("navigate", ({ url }) => console.log("now at", url))
8
+ // navigate("example.com")
9
+
10
+ import * as net from "./net.js"
11
+
12
+ // the server injects this into your html (see server/index.js)
13
+ const server_config = () => (typeof window !== "undefined" && window.__LITHIUM_CONFIG__) || {}
14
+
15
+ const state = {
16
+ proxy: null,
17
+ transport: null,
18
+ iface: null,
19
+ ready: false,
20
+ url: "", // last real URL the proxied page reported
21
+ search_engine: "google",
22
+ backend: null,
23
+ ctx: null,
24
+ poller: null,
25
+ unsubs: [], // listeners created from init_lithium's options
26
+ debug: false,
27
+ header_policy: { ...net.DEFAULT_POLICY },
28
+ network: [], // capped log of {method, url, status, ...}, see net.js
29
+ }
30
+ const NETWORK_LOG_CAP = 300
31
+
32
+ function dbg(...args) {
33
+ if (state.debug) console.log("[lithium debug]", ...args)
34
+ }
35
+
36
+ // window errors are useful in debug mode but easy to miss otherwise; this
37
+ // listens once at module load and checks state.debug live, so it works
38
+ // across re-init without adding/removing listeners each time
39
+ if (typeof window !== "undefined") {
40
+ window.addEventListener("error", (e) => { if (state.debug) console.error("[lithium debug] window error:", e.error ?? e.message) })
41
+ window.addEventListener("unhandledrejection", (e) => { if (state.debug) console.error("[lithium debug] unhandled rejection:", e.reason) })
42
+ }
43
+
44
+ // ------------------------------------------------------------------ events --
45
+ // "ready" () once init_lithium finished
46
+ // "navigate" ({ url, previousUrl }) whenever the proxied page changes URL,
47
+ // for every proxy
48
+ const listeners = new Map()
49
+
50
+ export function on(event, callback) {
51
+ if (typeof callback !== "function") throw new TypeError("[lithium] on(event, callback): callback must be a function")
52
+ if (!listeners.has(event)) listeners.set(event, new Set())
53
+ listeners.get(event).add(callback)
54
+ return () => off(event, callback) // handy: const stop = on(...); stop()
55
+ }
56
+
57
+ export function off(event, callback) {
58
+ listeners.get(event)?.delete(callback)
59
+ }
60
+
61
+ function emit(event, payload) {
62
+ for (const callback of [...(listeners.get(event) ?? [])]) {
63
+ try {
64
+ callback(payload)
65
+ } catch (err) {
66
+ console.error(`[lithium] a "${event}" listener threw:`, err)
67
+ }
68
+ }
69
+ }
70
+
71
+ function report_url(url) {
72
+ if (!url || url === state.url) return
73
+ const previousUrl = state.url
74
+ state.url = url
75
+ emit("navigate", { url, previousUrl })
76
+ }
77
+
78
+ // ------------------------------------------------------------------ config --
79
+ // Read-only view of what the server started with, plus the search engine.
80
+ export const config = {
81
+ get proxy() { return state.proxy ?? server_config().proxy ?? "ultraviolet" },
82
+ get transport() { return state.transport ?? server_config().transport ?? "epoxy" },
83
+ get interface() { return state.iface ?? server_config().interface ?? null },
84
+ get ready() { return state.ready },
85
+ get searchEngine() { return engine() },
86
+ set searchEngine(name) {
87
+ state.search_engine = name
88
+ localStorage.setItem("engine", name)
89
+ },
90
+ get debug() { return state.debug },
91
+ set debug(on) { state.debug = Boolean(on) },
92
+ // read-only snapshot (deep enough that mutating it can't affect live state);
93
+ // use set_header_policy() to change it live
94
+ get headers() { return { ...state.header_policy, block: [...state.header_policy.block], allow: state.header_policy.allow ? [...state.header_policy.allow] : null } },
95
+ }
96
+
97
+ // change the header policy without a full re-init (see net.js for what it can/can't do)
98
+ export function set_header_policy(policy = {}) {
99
+ state.header_policy = { ...state.header_policy, ...policy }
100
+ dbg("header policy set:", state.header_policy)
101
+ }
102
+
103
+ // [{ type: "fetch"|"xhr", method, url, status, ok, duration, ts, requestHeaders, responseHeaders, blockedRequestHeaders, blockedResponseHeaders }, ...]
104
+ // newest last, capped at 300 entries. See net.js for exactly what's covered.
105
+ export function network_log() {
106
+ return [...state.network]
107
+ }
108
+
109
+ export function clear_network_log() {
110
+ state.network.length = 0
111
+ }
112
+
113
+ // read every time so a settings page can change it on the fly
114
+ const engine = () => (typeof localStorage !== "undefined" && localStorage.getItem("engine")) || state.search_engine
115
+
116
+ // -------------------------------------------------------------------- setup --
117
+ // helper to load scripts dynamically
118
+ function load_script(src) {
119
+ return new Promise((resolve, reject) => {
120
+ const script = document.createElement("script")
121
+ script.src = src
122
+ script.onload = resolve
123
+ script.onerror = () => reject(new Error(`failed to load ${src}`))
124
+ document.head.appendChild(script)
125
+ })
126
+ }
127
+
128
+ // The browser only says "script evaluation failed" when a service worker can't
129
+ // start, which is nearly always an importScripts() that 404'd. Check every
130
+ // file the worker imports and say which one is missing.
131
+ async function diagnose_sw(proxy) {
132
+ const probe = async (path, note = "") => {
133
+ try {
134
+ const r = await fetch(path, { cache: "no-store" })
135
+ console.error(`[lithium] ${r.status} ${path} (${r.headers.get("content-type")}) ${note}`)
136
+ return r
137
+ } catch (e) {
138
+ console.error(`[lithium] failed ${path}: ${e.message}`)
139
+ }
140
+ }
141
+ console.error("[lithium] the service worker failed to start, checking the files it imports:")
142
+ if (proxy === "scramjet") {
143
+ await probe("/controller/controller.sw.js")
144
+ return
145
+ }
146
+ if (proxy !== "ultraviolet") {
147
+ await probe(`/sw.js?proxy=${proxy}`)
148
+ return
149
+ }
150
+ for (const p of ["/uv/uv.bundle.js", "/uv/uv.sw.js", "/uv/uv.handler.js", "/uv/uv.client.js"]) await probe(p)
151
+ const cfg = await probe("/uv/uv.config.js")
152
+ if (cfg?.ok) {
153
+ // the paths the config itself points at are what actually get imported
154
+ for (const m of (await cfg.text()).matchAll(/\b(handler|client|bundle|config|sw)\s*:\s*["']([^"']+)["']/g)) {
155
+ await probe(m[2], `<- config.${m[1]}`)
156
+ }
157
+ }
158
+ }
159
+
160
+ async function register_sw(proxy) {
161
+ let registration
162
+ try {
163
+ registration = await navigator.serviceWorker.register(`/sw.js?proxy=${proxy}`, {
164
+ scope: "/",
165
+ updateViaCache: "none",
166
+ })
167
+ } catch (err) {
168
+ await diagnose_sw(proxy).catch(() => {})
169
+ throw err
170
+ }
171
+ await navigator.serviceWorker.ready
172
+
173
+ // wait until the worker actually controls this page, or the first
174
+ // navigation can slip past it
175
+ if (!navigator.serviceWorker.controller) {
176
+ await new Promise((resolve) =>
177
+ navigator.serviceWorker.addEventListener("controllerchange", resolve, { once: true })
178
+ )
179
+ }
180
+ return navigator.serviceWorker.controller ?? registration.active
181
+ }
182
+
183
+ function wisp_url() {
184
+ return (location.protocol === "https:" ? "wss" : "ws") + "://" + location.host + "/wisp/"
185
+ }
186
+
187
+ // built-ins are bundled with the client, custom proxies name their own module
188
+ function load_backend(proxy, conf) {
189
+ if (proxy === "ultraviolet") return import("./backends/ultraviolet.js")
190
+ if (proxy === "scramjet") return import("./backends/scramjet.js")
191
+ if (!conf.clientModule) throw new Error(`proxy "${proxy}" has no client module (was it registered with clientModule?)`)
192
+ return import(conf.clientModule)
193
+ }
194
+
195
+ // the iframe pages are shown in: #proxyFrame if you have one, else we make it
196
+ // (create=false: just look, don't make one)
197
+ function get_iframe(create = true) {
198
+ let iframe = document.getElementById("proxyFrame")
199
+ if (!iframe && create) {
200
+ iframe = document.createElement("iframe")
201
+ iframe.id = "proxyFrame"
202
+ iframe.style.cssText = "width:100%;height:100%;border:0"
203
+ ;(document.getElementById("container") || document.body).appendChild(iframe)
204
+ }
205
+ // network log + header policy: reinstalled every real navigation, since a
206
+ // new document means a new window. See net.js for what this can/can't see.
207
+ if (iframe && !iframe.__lithiumNetBound) {
208
+ iframe.__lithiumNetBound = true
209
+ iframe.addEventListener("load", () => {
210
+ try {
211
+ net.install(iframe.contentWindow, {
212
+ get_policy: () => state.header_policy,
213
+ emit: (entry) => {
214
+ state.network.push(entry)
215
+ if (state.network.length > NETWORK_LOG_CAP) state.network.shift()
216
+ emit("request", entry)
217
+ },
218
+ dbg,
219
+ })
220
+ } catch (err) {
221
+ dbg("network hook install failed:", err)
222
+ }
223
+ })
224
+ }
225
+ return iframe
226
+ }
227
+
228
+ // init the thing
229
+ // searchEngine "google" | "duckduckgo"
230
+ // onReady () => void (same as on("ready", ...))
231
+ // onUrlChange (url) => void (same as on("navigate", ...), all proxies)
232
+ export async function init_lithium(cfg = {}) {
233
+ const conf = server_config()
234
+ // same defaults as the server
235
+ const proxy = conf.proxy || "ultraviolet"
236
+ const transport = conf.transport || "epoxy"
237
+ const transport_url = conf.transportUrl || (proxy === "scramjet" ? `/${transport}/index.mjs` : `/bm/${transport}/index.mjs`)
238
+
239
+ // re-init: forget the previous run
240
+ for (const stop of state.unsubs) stop()
241
+ state.unsubs = []
242
+ clearInterval(state.poller)
243
+ state.ready = false
244
+ state.url = ""
245
+
246
+ state.proxy = proxy
247
+ state.transport = transport
248
+ state.iface = conf.interface ?? null
249
+ state.search_engine = cfg.searchEngine || "google"
250
+ state.debug = Boolean(cfg.debug)
251
+ state.header_policy = { ...net.DEFAULT_POLICY, ...(cfg.headers ?? {}) }
252
+ if (cfg.onReady) state.unsubs.push(on("ready", () => cfg.onReady()))
253
+ if (cfg.onUrlChange) state.unsubs.push(on("navigate", ({ url }) => cfg.onUrlChange(url)))
254
+
255
+ // keep these around so your own ui can read them
256
+ localStorage.setItem("proxy", proxy)
257
+ localStorage.setItem("transport", transport)
258
+ localStorage.setItem("engine", state.search_engine)
259
+ console.log("[lithium] config:", { proxy, transport })
260
+ dbg("init_lithium options:", cfg)
261
+ dbg("header policy:", state.header_policy)
262
+
263
+ try {
264
+ dbg(`registering service worker for "${proxy}"`)
265
+ const serviceworker = await register_sw(proxy)
266
+ dbg("service worker ready, loading backend module")
267
+ const backend = await load_backend(proxy, conf)
268
+ const ctx = {
269
+ proxy, transport, transport_url, serviceworker,
270
+ wisp_url: wisp_url(),
271
+ load_script,
272
+ iframe: get_iframe,
273
+ report_url,
274
+ state: {}, // for the backend's own use
275
+ }
276
+ await backend.init(ctx)
277
+ dbg("backend init complete")
278
+ state.backend = backend
279
+ state.ctx = ctx
280
+
281
+ if (typeof backend.current_url === "function") {
282
+ state.poller = setInterval(() => {
283
+ try { report_url(backend.current_url(ctx)) } catch { /* frame not loaded yet */ }
284
+ }, 500)
285
+ }
286
+ } catch (err) {
287
+ console.error("[lithium] init failed:", err)
288
+ if (state.debug) console.error("[lithium debug] full error:", err)
289
+ throw err // don't pretend we're ready
290
+ }
291
+
292
+ state.ready = true
293
+ emit("ready")
294
+ }
295
+
296
+ // ---------------------------------------------------------------- navigation --
297
+ async function go(url) {
298
+ if (!state.ready) {
299
+ console.error("[lithium] not ready yet, await init_lithium() first")
300
+ return
301
+ }
302
+ get_iframe() // make sure it exists
303
+ document.getElementById("container")?.classList.add("browsing")
304
+ await state.backend.navigate(url, state.ctx)
305
+ }
306
+
307
+ // go somewhere on the internet: a URL, a domain, or a search
308
+ export function navigate(input) {
309
+ const url = to_url(input, engine())
310
+ dbg("navigate ->", url)
311
+ return go(url)
312
+ }
313
+
314
+ // always a search, even if the text looks like a URL
315
+ export function search(query) {
316
+ return go(search_url(query, engine()))
317
+ }
318
+
319
+ // the real URL of the page being shown ("" until something was loaded)
320
+ export function current_url() {
321
+ return state.url
322
+ }
323
+
324
+ // history actions on the proxied page (works the same for every proxy)
325
+ function with_frame(action, fn) {
326
+ try {
327
+ const win = state.ready ? get_iframe(false)?.contentWindow : null
328
+ if (win) fn(win)
329
+ } catch (err) {
330
+ console.error(`[lithium] ${action} failed:`, err)
331
+ }
332
+ }
333
+ export const back = () => with_frame("back", (w) => w.history.back())
334
+ export const forward = () => with_frame("forward", (w) => w.history.forward())
335
+ export const reload = () => with_frame("reload", (w) => w.location.reload())
336
+
337
+ // ------------------------------------------------------------------ url utils --
338
+ // checks if its a url or some random junk
339
+ export function is_url(v = "") {
340
+ v = v.trim()
341
+ if (/^https?:\/\//i.test(v)) return true
342
+ if (/\s/.test(v)) return false // "what is 3.14" is a search, not a url
343
+ return (
344
+ /^[^\s/?#]+\.[a-z]{2,}(:\d+)?([/?#]|$)/i.test(v) || // example.com, a.b.co/path
345
+ /^\d{1,3}(\.\d{1,3}){3}(:\d+)?([/?#]|$)/.test(v) // 192.168.1.1:8080
346
+ )
347
+ }
348
+
349
+ export function search_url(query, engine = "google") {
350
+ const q = encodeURIComponent(String(query).trim())
351
+ return engine === "duckduckgo" ? `https://duckduckgo.com/?q=${q}` : `https://www.google.com/search?q=${q}`
352
+ }
353
+
354
+ // turn whatever was typed into a full url (or a search url)
355
+ export function to_url(input, engine = "google") {
356
+ const val = input.trim()
357
+ if (!is_url(val)) return search_url(val, engine)
358
+ return /^https?:\/\//i.test(val) ? val : "https://" + val
359
+ }
package/client/net.js ADDED
@@ -0,0 +1,114 @@
1
+ // Best-effort network visibility + header policy for the proxied page.
2
+ //
3
+ // SCOPE, HONESTLY: Lithium's Node server never sees the proxied site's raw
4
+ // HTTP traffic (that goes browser -> service worker -> wisp tunnel -> the
5
+ // real site, as bytes, not parsed HTTP). So this works client-side instead:
6
+ // it's installed on the proxied iframe's window (same-origin, since that's
7
+ // how UV/Scramjet serve proxied pages) and wraps fetch()/XMLHttpRequest.
8
+ // - fetch(): full control. Request headers are filtered before sending,
9
+ // and the Response is rebuilt with filtered headers before the page's
10
+ // own code sees it.
11
+ // - XMLHttpRequest: observed only (logged), the header policy is NOT
12
+ // enforced on it — rebuilding a native XHR's headers isn't practical.
13
+ // - Anything that isn't fetch/XHR (<img>, <script src>, <link>, CSS,
14
+ // etc.) is invisible here entirely.
15
+ // - Only requests made AFTER this installs are seen. It installs on the
16
+ // iframe's "load" event, so very early/synchronous requests on that
17
+ // page can be missed.
18
+
19
+ export const DEFAULT_POLICY = { mode: "filter", block: [], allow: null }
20
+
21
+ // mode: "passthrough" -> do nothing (headers pass exactly as the proxy already
22
+ // delivered them); "filter" -> apply block/allow
23
+ // block: header names (case-insensitive) to strip
24
+ // allow: if an array, ONLY these header names survive (block still applies on top)
25
+ export function apply_policy(headers, policy) {
26
+ if (policy.mode === "passthrough") return { headers, blocked: [] }
27
+ const out = new Headers()
28
+ const blocked = []
29
+ const block = new Set((policy.block ?? []).map((h) => h.toLowerCase()))
30
+ const allow = policy.allow ? new Set(policy.allow.map((h) => h.toLowerCase())) : null
31
+ for (const [k, v] of headers.entries()) {
32
+ const lk = k.toLowerCase()
33
+ if (block.has(lk) || (allow && !allow.has(lk))) {
34
+ blocked.push(k)
35
+ continue
36
+ }
37
+ out.append(k, v)
38
+ }
39
+ return { headers: out, blocked }
40
+ }
41
+
42
+ const headers_to_object = (headers) => Object.fromEntries(headers.entries())
43
+
44
+ // win: the proxied iframe's contentWindow
45
+ // get_policy() -> current policy (read live, so changes apply mid-session)
46
+ // emit(entry) -> called once per completed request
47
+ // dbg(...) -> debug logger, called for policy decisions
48
+ export function install(win, { get_policy, emit, dbg = () => {} }) {
49
+ if (!win || win.__lithiumNetHooked) return
50
+ win.__lithiumNetHooked = true
51
+
52
+ const orig_fetch = win.fetch?.bind(win)
53
+ if (orig_fetch) {
54
+ win.fetch = async (input, init = {}) => {
55
+ const policy = get_policy()
56
+ const url = typeof input === "string" ? input : input.url
57
+ const method = (init.method || (input instanceof Request ? input.method : "GET") || "GET").toUpperCase()
58
+ const started = win.performance?.now?.() ?? Date.now()
59
+
60
+ let req_headers = new Headers(init.headers || (input instanceof Request ? input.headers : undefined) || {})
61
+ const { headers: filtered_req, blocked: blocked_req } = apply_policy(req_headers, policy)
62
+ if (blocked_req.length) dbg(`blocked request headers on ${url}: ${blocked_req.join(", ")}`)
63
+
64
+ const finish = (fields) => emit({ type: "fetch", method, url, ts: Date.now(), duration: Math.round((win.performance?.now?.() ?? Date.now()) - started), requestHeaders: headers_to_object(filtered_req), blockedRequestHeaders: blocked_req, blockedResponseHeaders: [], responseHeaders: null, ...fields })
65
+
66
+ let response
67
+ try {
68
+ response = await orig_fetch(input instanceof Request ? new Request(input, { headers: filtered_req }) : input, { ...init, headers: filtered_req })
69
+ } catch (err) {
70
+ finish({ status: 0, ok: false, error: err.message })
71
+ throw err
72
+ }
73
+
74
+ if (policy.mode === "passthrough") {
75
+ finish({ status: response.status, ok: response.ok, responseHeaders: headers_to_object(response.headers) })
76
+ return response
77
+ }
78
+ const { headers: filtered_res, blocked: blocked_res } = apply_policy(response.headers, policy)
79
+ if (blocked_res.length) dbg(`blocked response headers on ${url}: ${blocked_res.join(", ")}`)
80
+ finish({ status: response.status, ok: response.ok, responseHeaders: headers_to_object(filtered_res), blockedResponseHeaders: blocked_res })
81
+ if (!blocked_res.length) return response
82
+ const body = await response.clone().blob()
83
+ return new Response(body, { status: response.status, statusText: response.statusText, headers: filtered_res })
84
+ }
85
+ }
86
+
87
+ const OrigXHR = win.XMLHttpRequest
88
+ if (OrigXHR) {
89
+ win.XMLHttpRequest = class extends OrigXHR {
90
+ open(method, url, ...rest) {
91
+ this.__lithium = { method, url, started: win.performance?.now?.() ?? Date.now() }
92
+ return super.open(method, url, ...rest)
93
+ }
94
+ send(...args) {
95
+ this.addEventListener("loadend", () => {
96
+ const info = this.__lithium || {}
97
+ let responseHeaders = null
98
+ try {
99
+ responseHeaders = Object.fromEntries(
100
+ this.getAllResponseHeaders().trim().split(/\r?\n/).filter(Boolean).map((l) => { const i = l.indexOf(":"); return [l.slice(0, i).trim(), l.slice(i + 1).trim()] })
101
+ )
102
+ } catch {}
103
+ emit({
104
+ type: "xhr", method: info.method || "GET", url: info.url || "", status: this.status, ok: this.status >= 200 && this.status < 400,
105
+ duration: Math.round((win.performance?.now?.() ?? Date.now()) - (info.started ?? 0)), ts: Date.now(),
106
+ requestHeaders: null, responseHeaders, blockedRequestHeaders: [], blockedResponseHeaders: [],
107
+ note: "XHR is observed only, header policy isn't enforced on it",
108
+ })
109
+ })
110
+ return super.send(...args)
111
+ }
112
+ }
113
+ }
114
+ }
package/client/sw.js ADDED
@@ -0,0 +1,33 @@
1
+ // lithium service worker
2
+ // Registered as /sw.js?proxy=<ultraviolet|scramjet> by client/index.js, and
3
+ // only loads the code for the proxy it was asked for. Has to be served from
4
+ // the site root so its scope covers the proxied routes.
5
+ const proxy = new URL(self.location.href).searchParams.get("proxy") || "ultraviolet"
6
+
7
+ // Without these, the worker only takes control after a reload, so the very
8
+ // first request from the iframe would skip the proxy entirely.
9
+ self.addEventListener("install", () => self.skipWaiting())
10
+ self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()))
11
+
12
+ if (proxy === "scramjet") {
13
+ // Scramjet 2.x: the controller decides what to route. shouldRoute() is
14
+ // false for its own runtime files, so they don't get intercepted.
15
+ importScripts("/controller/controller.sw.js")
16
+
17
+ self.addEventListener("fetch", (event) => {
18
+ if ($scramjetController.shouldRoute(event)) {
19
+ event.respondWith($scramjetController.route(event))
20
+ }
21
+ })
22
+ } else {
23
+ // Ultraviolet 3.x (transport is handled by bare-mux on the page side)
24
+ importScripts("/uv/uv.bundle.js")
25
+ importScripts("/uv/uv.config.js")
26
+ importScripts(self.__uv$config.sw || "/uv/uv.sw.js")
27
+
28
+ const uv = new UVServiceWorker()
29
+
30
+ self.addEventListener("fetch", (event) => {
31
+ if (uv.route(event)) event.respondWith(uv.fetch(event))
32
+ })
33
+ }
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@nivalos/lithium.js",
3
+ "version": "1.1.0",
4
+ "description": "Creating a proxy has never been easier with Lithium.js",
5
+ "type": "module",
6
+ "main": "server/index.js",
7
+ "engines": {
8
+ "node": ">=24"
9
+ },
10
+ "exports": {
11
+ ".": "./server/index.js"
12
+ },
13
+ "scripts": {
14
+ "start": "node server/start.js",
15
+ "test": "echo \"no automated tests are bundled, run `npx lithium doctor` to check your install\""
16
+ },
17
+ "keywords": [
18
+ "lithium",
19
+ "lithium-js",
20
+ "lithium-proxy",
21
+ "scramjet",
22
+ "ultraviolet",
23
+ "bare-mux",
24
+ "epoxy-transport",
25
+ "libcurl-transport",
26
+ "wisp-js",
27
+ "mercuryworkshop",
28
+ "proxy-server",
29
+ "web-unblocker",
30
+ "nodejs",
31
+ "express",
32
+ "self-hosted-proxy",
33
+ "reverse-proxy",
34
+ "bypass",
35
+ "webview",
36
+ "frontend-proxy",
37
+ "uv-proxy",
38
+ "school-unblocker",
39
+ "browser-proxy",
40
+ "javascript",
41
+ "network-tunneling"
42
+ ],
43
+ "author": "nivalos",
44
+ "license": "AGPL-3.0-or-later",
45
+ "dependencies": {
46
+ "@mercuryworkshop/bare-mux": "^2.1.9",
47
+ "@mercuryworkshop/epoxy-transport": "^3.0.1",
48
+ "@mercuryworkshop/libcurl-transport": "^2.0.5",
49
+ "@mercuryworkshop/scramjet": "2.0.67-alpha.2",
50
+ "@mercuryworkshop/scramjet-controller": "0.0.14",
51
+ "@mercuryworkshop/scramjet-utils": "0.0.3",
52
+ "@mercuryworkshop/wisp-js": "^0.5.0",
53
+ "@titaniumnetwork-dev/ultraviolet": "^3.2.10",
54
+ "cheerio": "^1.2.0",
55
+ "epoxy-transport-bm": "npm:@mercuryworkshop/epoxy-transport@^2.1.28",
56
+ "express": "^5.2.1",
57
+ "libcurl-transport-bm": "npm:@mercuryworkshop/libcurl-transport@^1.5.2"
58
+ },
59
+ "files": [
60
+ "server",
61
+ "client",
62
+ "bin",
63
+ "README.md",
64
+ "LICENSE"
65
+ ],
66
+ "bin": {
67
+ "lithium": "bin/lithium.js"
68
+ }
69
+ }
@@ -0,0 +1,16 @@
1
+ // Minimal ANSI color helper for terminal output (zero dependencies, like a
2
+ // tiny colorama for Node). Auto-disables when stdout isn't a TTY or NO_COLOR
3
+ // is set (https://no-color.org); force with FORCE_COLOR=1.
4
+ // import { color, set_color } from "./color.js"
5
+ // console.log(color.green("ok"), color.red("failed"))
6
+ const is_tty = () => Boolean(process.stdout && process.stdout.isTTY)
7
+ let enabled = process.env.FORCE_COLOR ? true : !process.env.NO_COLOR && is_tty()
8
+
9
+ export function set_color(on) { enabled = Boolean(on) }
10
+ export function color_enabled() { return enabled }
11
+
12
+ const wrap = (open, close) => (s) => (enabled ? `\x1b[${open}m${s}\x1b[${close}m` : String(s))
13
+ export const color = {
14
+ red: wrap(31, 39), green: wrap(32, 39), yellow: wrap(33, 39), blue: wrap(34, 39),
15
+ magenta: wrap(35, 39), cyan: wrap(36, 39), gray: wrap(90, 39), bold: wrap(1, 22), dim: wrap(2, 22),
16
+ }