@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.
- package/LICENSE +661 -0
- package/README.md +317 -0
- package/bin/lithium.js +40 -0
- package/client/backends/scramjet.js +53 -0
- package/client/backends/ultraviolet.js +30 -0
- package/client/index.js +359 -0
- package/client/net.js +114 -0
- package/client/sw.js +33 -0
- package/package.json +69 -0
- package/server/color.js +16 -0
- package/server/doctor.js +132 -0
- package/server/errors.js +21 -0
- package/server/index.js +238 -0
- package/server/meta.js +55 -0
- package/server/pkgs.js +91 -0
- package/server/registry.js +262 -0
- package/server/start.js +14 -0
package/client/index.js
ADDED
|
@@ -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
|
+
}
|
package/server/color.js
ADDED
|
@@ -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
|
+
}
|