@nivalos/lithium.js 1.1.0 → 1.6.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/README.md CHANGED
@@ -17,13 +17,14 @@ A flexible web proxy framework to make your skid dream a reality.
17
17
  - **`lithium doctor`**: one command to check your whole install
18
18
  - **Events + history**: `on("navigate", ...)`, `back()`/`forward()`/`reload()`/`search()`, works the same for every proxy
19
19
  - **Debug mode + network log**: `debug: true` for verbose tracing, `network_log()`/`on("request", ...)` for a devtools-lite view of the proxied page's `fetch`/XHR traffic
20
- - **Header policy**: block/allow specific headers, or bypass Lithium's interception entirely with `passthrough` mode
20
+ - **Header policy**: block/allow/override specific headers, spoof device fingerprint (`navigator`/`screen`), or bypass interception entirely with `passthrough` mode
21
+ - **Custom search engines**: register your own, with per-engine header overrides
21
22
  - **Modular design**: clean separation of client and server code, custom backends are first-class
22
23
 
23
24
  ## Installation
24
25
 
25
26
  ```bash
26
- npm install lithium.js
27
+ npm install @nivalos/lithium.js
27
28
  ```
28
29
 
29
30
  Scramjet 2.x is published under the `alpha` tag and Lithium pins the exact
@@ -214,16 +215,40 @@ Lithium's Node server never sees the proxied site's raw HTTP (that goes browser
214
215
  ```javascript
215
216
  await init_lithium({
216
217
  headers: {
217
- mode: "filter", // "filter" (default) or "passthrough" (touch nothing)
218
- block: ["x-frame-options"], // stripped from requests AND responses, case-insensitive
219
- allow: null, // if an array, ONLY these header names survive
218
+ mode: "filter", // "filter" (default) or "passthrough" (touch nothing)
219
+ block: ["x-frame-options"], // stripped from requests AND responses, case-insensitive
220
+ allow: null, // if an array, ONLY these header names survive
221
+ set: { "x-custom": "value" }, // added/overridden on outgoing requests
222
+ searchEngineHeaders: { "x-a": "b" }, // extra `set` overrides, ONLY on requests to the current search engine's origin
223
+ referrer: "https://example.com/", // passed to fetch()'s own `referrer` option (Referer can't be set via headers at all)
224
+ device: { // spoofs navigator/screen on the proxied page, see "Device spoofing" below
225
+ userAgent: "Mozilla/5.0 (...) Custom/1.0",
226
+ platform: "Win32",
227
+ },
220
228
  },
221
229
  })
222
- set_header_policy({ mode: "passthrough" }) // change it any time, no re-init needed
230
+ set_header_policy({ mode: "passthrough" }) // change any of this any time, no re-init needed
223
231
  ```
224
232
 
225
233
  **Scope, honestly:** this rebuilds the `Response` object `fetch()` hands to the page, so it's real for anything the page's own JS reads. `XMLHttpRequest` traffic is logged but the policy isn't enforced on it. `<img>`, `<script src>`, `<link>`, and CSS loads never go through JS at all, so they're invisible to this — there's no hook point for them at this layer.
226
234
 
235
+ **A real browser restriction, not a Lithium limitation:** a set of header names — `Cookie`, `Host`, `Origin`, `Connection`, anything starting with `Sec-`/`Proxy-`, and a few others — are "forbidden request headers" per the Fetch spec. No page JS anywhere can set them; the browser silently drops them when it builds the real request. `Referer` is on that list too, which is why it's handled separately via `referrer` above rather than through `set`/`block`/`allow` (put it in `set.referer` if that's more convenient — Lithium notices and routes it to the right place for you). `User-Agent` isn't spec-forbidden anymore, but Chrome still silently drops it from `fetch()` regardless (a long-standing Chromium quirk); Firefox honors it. `network_log()` entries include a `caveats` note whenever a header you configured falls into one of these categories, so you're not left guessing why it didn't take.
236
+
237
+ ### Device spoofing
238
+
239
+ The `device` option overrides `navigator`/`screen` properties (`userAgent`, `platform`, `vendor`, `language`, `languages`, `maxTouchPoints`, `hardwareConcurrency`, `deviceMemory`, `userAgentData`, `screen: { width, height, ... }`) on the proxied page, so the *site's own JavaScript* — feature detection, analytics, fingerprinting — sees a different device. This is unrestricted (none of the forbidden-header rules above apply to JS properties), but it only fools client-side JS, not a server reading real HTTP headers, and only for reads that happen after this installs (same per-navigation timing caveat as everything else in this section).
240
+
241
+ ## Custom search engines
242
+
243
+ ```javascript
244
+ import { search_engines } from "lithium.js" // or a client-only import in the browser
245
+
246
+ search_engines.register("bing", (query) => `https://www.bing.com/search?q=${encodeURIComponent(query)}`)
247
+ await init_lithium({ searchEngine: "bing" }) // or: config.searchEngine = "bing" any time
248
+ ```
249
+
250
+ `search()`/`navigate()` with a non-URL query, and the `searchEngineHeaders` scoping above, all use whichever engine is currently set. An unregistered name silently falls back to Google rather than breaking navigation.
251
+
227
252
  ## Network log
228
253
 
229
254
  ```javascript
package/bin/lithium.js CHANGED
File without changes
package/client/index.js CHANGED
@@ -8,6 +8,8 @@
8
8
  // navigate("example.com")
9
9
 
10
10
  import * as net from "./net.js"
11
+ import { search_engines, build_search_url, search_origin } from "./search.js"
12
+ export { search_engines }
11
13
 
12
14
  // the server injects this into your html (see server/index.js)
13
15
  const server_config = () => (typeof window !== "undefined" && window.__LITHIUM_CONFIG__) || {}
@@ -91,7 +93,9 @@ export const config = {
91
93
  set debug(on) { state.debug = Boolean(on) },
92
94
  // read-only snapshot (deep enough that mutating it can't affect live state);
93
95
  // 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 } },
96
+ // read-only deep-enough snapshot (mutating it can't affect live state);
97
+ // use set_header_policy() to change it live
98
+ get headers() { return JSON.parse(JSON.stringify(state.header_policy)) },
95
99
  }
96
100
 
97
101
  // change the header policy without a full re-init (see net.js for what it can/can't do)
@@ -216,6 +220,7 @@ function get_iframe(create = true) {
216
220
  emit("request", entry)
217
221
  },
218
222
  dbg,
223
+ search_origin: () => search_origin(engine()),
219
224
  })
220
225
  } catch (err) {
221
226
  dbg("network hook install failed:", err)
@@ -346,9 +351,10 @@ export function is_url(v = "") {
346
351
  )
347
352
  }
348
353
 
354
+ // engine can be "google", "duckduckgo", or anything registered via
355
+ // search_engines.register()
349
356
  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}`
357
+ return build_search_url(query, engine)
352
358
  }
353
359
 
354
360
  // turn whatever was typed into a full url (or a search url)
package/client/net.js CHANGED
@@ -5,9 +5,8 @@
5
5
  // real site, as bytes, not parsed HTTP). So this works client-side instead:
6
6
  // it's installed on the proxied iframe's window (same-origin, since that's
7
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.
8
+ // - fetch(): full control over request headers going out and the Response
9
+ // the page's own code sees coming back.
11
10
  // - XMLHttpRequest: observed only (logged), the header policy is NOT
12
11
  // enforced on it — rebuilding a native XHR's headers isn't practical.
13
12
  // - Anything that isn't fetch/XHR (<img>, <script src>, <link>, CSS,
@@ -15,15 +14,62 @@
15
14
  // - Only requests made AFTER this installs are seen. It installs on the
16
15
  // iframe's "load" event, so very early/synchronous requests on that
17
16
  // page can be missed.
17
+ //
18
+ // A real browser restriction this can't route around: a set of header names
19
+ // ("forbidden request headers" in the Fetch spec — Cookie, Host, Origin,
20
+ // Connection, and anything starting with Sec-/Proxy-, among others) can't be
21
+ // set from page JS at all; the browser silently drops them when it builds
22
+ // the actual request, no matter what's in the Headers object handed to
23
+ // fetch(). Referer is spec-forbidden the same way, but fetch() has a
24
+ // dedicated `referrer` option that DOES work, so that one's routed there
25
+ // instead. User-Agent is no longer spec-forbidden, but Chrome still silently
26
+ // drops it from fetch() requests regardless (a long-standing Chromium
27
+ // quirk); Firefox honors it. See is_unforgeable_header()/header_set_caveat().
28
+
29
+ export const DEFAULT_POLICY = {
30
+ mode: "filter",
31
+ block: [],
32
+ allow: null,
33
+ set: {}, // headers to add/override on outgoing requests (subject to the browser restrictions above)
34
+ searchEngineHeaders: {}, // extra overrides, applied ONLY when the request's origin is the current search engine's
35
+ device: null, // { userAgent, platform, vendor, language, languages, maxTouchPoints, hardwareConcurrency, deviceMemory, userAgentData, screen } spoofed on navigator/screen
36
+ referrer: undefined, // passed straight to fetch()'s `referrer` option
37
+ referrerPolicy: undefined,
38
+ }
39
+
40
+ const UNFORGEABLE_HEADERS = new Set([
41
+ "accept-charset", "accept-encoding", "access-control-request-headers", "access-control-request-method",
42
+ "connection", "content-length", "cookie", "cookie2", "date", "dnt", "expect", "host", "keep-alive",
43
+ "origin", "permissions-policy", "referer", "set-cookie", "te", "trailer", "transfer-encoding", "upgrade", "via",
44
+ ])
45
+
46
+ // true for header names the browser will silently refuse to send from page
47
+ // JS no matter what Lithium does — see the file header for why
48
+ export function is_unforgeable_header(name) {
49
+ const lk = String(name).toLowerCase()
50
+ return UNFORGEABLE_HEADERS.has(lk) || lk.startsWith("sec-") || lk.startsWith("proxy-")
51
+ }
18
52
 
19
- export const DEFAULT_POLICY = { mode: "filter", block: [], allow: null }
53
+ // a one-line explanation for a header name in a `set`/`searchEngineHeaders`
54
+ // map, or null if there's nothing special to say about it
55
+ export function header_set_caveat(name) {
56
+ const lk = String(name).toLowerCase()
57
+ if (lk === "user-agent") return "not spec-forbidden, but Chrome silently drops it from fetch() requests anyway (Chromium bug 571722); Firefox honors it"
58
+ if (lk === "referer" || lk === "referrer") return "Referer can't be set via headers at all; use policy.referrer instead (Lithium routes set.referer there for you)"
59
+ if (is_unforgeable_header(lk)) return "forbidden by the Fetch spec — the browser drops it silently, there's no workaround from page JS"
60
+ return null
61
+ }
62
+
63
+ // mode: "passthrough" -> do nothing (headers pass exactly as the proxy
64
+ // already delivered them, including ignoring set/searchEngineHeaders);
65
+ // "filter" -> apply block/allow, then (for request headers only) set/searchEngineHeaders
66
+ // direction: "request" | "response" — set/searchEngineHeaders/referrer only make sense for
67
+ // outgoing requests (they're about how WE identify ourselves), not responses
68
+ // search_engine_match: whether this request's origin is the current search engine's,
69
+ // enabling searchEngineHeaders on top of set
70
+ export function apply_policy(headers, policy, { direction = "request", search_engine_match = false } = {}) {
71
+ if (policy.mode === "passthrough") return { headers, blocked: [], overridden: [], caveats: [], referrer: undefined }
20
72
 
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
73
  const out = new Headers()
28
74
  const blocked = []
29
75
  const block = new Set((policy.block ?? []).map((h) => h.toLowerCase()))
@@ -36,19 +82,92 @@ export function apply_policy(headers, policy) {
36
82
  }
37
83
  out.append(k, v)
38
84
  }
39
- return { headers: out, blocked }
85
+
86
+ if (direction !== "request") return { headers: out, blocked, overridden: [], caveats: [], referrer: undefined }
87
+
88
+ const overrides = { ...(policy.set ?? {}), ...(search_engine_match ? (policy.searchEngineHeaders ?? {}) : {}) }
89
+ const overridden = []
90
+ const caveats = []
91
+ let referrer = policy.referrer
92
+ for (const [name, value] of Object.entries(overrides)) {
93
+ const lk = name.toLowerCase()
94
+ if (lk === "referer" || lk === "referrer") {
95
+ referrer ??= value // an explicit policy.referrer still wins over one smuggled in via `set`
96
+ caveats.push({ name, note: header_set_caveat(name) })
97
+ continue // don't bother putting a header in that fetch() would drop anyway
98
+ }
99
+ out.set(name, value)
100
+ overridden.push(name)
101
+ const note = header_set_caveat(name)
102
+ if (note) caveats.push({ name, note })
103
+ }
104
+ return { headers: out, blocked, overridden, caveats, referrer }
105
+ }
106
+
107
+ // Overrides properties of navigator/screen on the proxied window so the
108
+ // SITE'S OWN JS (feature detection, analytics, fingerprinting) sees a
109
+ // different device. This is unrestricted (no Fetch-spec forbidden list to
110
+ // fight), unlike request headers — but it only fools JS, not a server that
111
+ // inspects real HTTP headers, and it only takes effect for code that reads
112
+ // these properties AFTER this installs (see the file header).
113
+ // Returns which property names were successfully overridden.
114
+ export function apply_device_profile(win, device) {
115
+ if (!device || !win?.navigator) return []
116
+ const applied = []
117
+ const nav = win.navigator
118
+ const simple = ["userAgent", "platform", "vendor", "language", "languages", "maxTouchPoints", "hardwareConcurrency", "deviceMemory"]
119
+ for (const key of simple) {
120
+ if (device[key] === undefined) continue
121
+ try {
122
+ Object.defineProperty(nav, key, { get: () => device[key], configurable: true })
123
+ applied.push(key)
124
+ } catch {}
125
+ }
126
+ if (device.userAgentData) {
127
+ const uad = device.userAgentData
128
+ try {
129
+ Object.defineProperty(nav, "userAgentData", {
130
+ configurable: true,
131
+ get: () => ({
132
+ brands: uad.brands ?? [],
133
+ mobile: Boolean(uad.mobile),
134
+ platform: uad.platform ?? "",
135
+ toJSON: () => ({ brands: uad.brands ?? [], mobile: Boolean(uad.mobile), platform: uad.platform ?? "" }),
136
+ getHighEntropyValues: async (hints) =>
137
+ Object.fromEntries((hints ?? []).map((h) => [h, h in uad ? uad[h] : h === "brands" ? uad.brands ?? [] : h === "mobile" ? Boolean(uad.mobile) : h === "platform" ? uad.platform ?? "" : null])),
138
+ }),
139
+ })
140
+ applied.push("userAgentData")
141
+ } catch {}
142
+ }
143
+ if (device.screen && win.screen) {
144
+ for (const [key, value] of Object.entries(device.screen)) {
145
+ try {
146
+ Object.defineProperty(win.screen, key, { get: () => value, configurable: true })
147
+ applied.push(`screen.${key}`)
148
+ } catch {}
149
+ }
150
+ }
151
+ return applied
40
152
  }
41
153
 
42
154
  const headers_to_object = (headers) => Object.fromEntries(headers.entries())
43
155
 
44
156
  // 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 = () => {} }) {
157
+ // get_policy() -> current policy (read live, so changes apply mid-session)
158
+ // emit(entry) -> called once per completed request
159
+ // dbg(...) -> debug logger
160
+ // search_origin() -> the current search engine's origin, or null (for searchEngineHeaders scoping)
161
+ export function install(win, { get_policy, emit, dbg = () => {}, search_origin = () => null }) {
49
162
  if (!win || win.__lithiumNetHooked) return
50
163
  win.__lithiumNetHooked = true
51
164
 
165
+ const initial_policy = get_policy()
166
+ if (initial_policy.device) {
167
+ const applied = apply_device_profile(win, initial_policy.device)
168
+ dbg(`device profile applied: ${applied.length ? applied.join(", ") : "(nothing — properties weren't configurable here)"}`)
169
+ }
170
+
52
171
  const orig_fetch = win.fetch?.bind(win)
53
172
  if (orig_fetch) {
54
173
  win.fetch = async (input, init = {}) => {
@@ -56,16 +175,29 @@ export function install(win, { get_policy, emit, dbg = () => {} }) {
56
175
  const url = typeof input === "string" ? input : input.url
57
176
  const method = (init.method || (input instanceof Request ? input.method : "GET") || "GET").toUpperCase()
58
177
  const started = win.performance?.now?.() ?? Date.now()
178
+ const origin = search_origin()
179
+ const is_search_request = Boolean(origin) && url.startsWith(origin)
59
180
 
60
181
  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(", ")}`)
182
+ const req_result = apply_policy(req_headers, policy, { direction: "request", search_engine_match: is_search_request })
183
+ req_headers = req_result.headers
184
+ if (req_result.blocked.length) dbg(`blocked request headers on ${url}: ${req_result.blocked.join(", ")}`)
185
+ for (const c of req_result.caveats) if (c.note) dbg(`request header "${c.name}" on ${url}: ${c.note}`)
186
+
187
+ const finish = (fields) =>
188
+ emit({
189
+ type: "fetch", method, url, ts: Date.now(), duration: Math.round((win.performance?.now?.() ?? Date.now()) - started),
190
+ requestHeaders: headers_to_object(req_headers), blockedRequestHeaders: req_result.blocked, blockedResponseHeaders: [], responseHeaders: null,
191
+ ...fields,
192
+ })
63
193
 
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 })
194
+ const fetch_init = { ...init, headers: req_headers }
195
+ if (req_result.referrer !== undefined) fetch_init.referrer = req_result.referrer
196
+ if (policy.referrerPolicy) fetch_init.referrerPolicy = policy.referrerPolicy
65
197
 
66
198
  let response
67
199
  try {
68
- response = await orig_fetch(input instanceof Request ? new Request(input, { headers: filtered_req }) : input, { ...init, headers: filtered_req })
200
+ response = await orig_fetch(input instanceof Request ? new Request(input, { headers: req_headers }) : input, fetch_init)
69
201
  } catch (err) {
70
202
  finish({ status: 0, ok: false, error: err.message })
71
203
  throw err
@@ -75,12 +207,12 @@ export function install(win, { get_policy, emit, dbg = () => {} }) {
75
207
  finish({ status: response.status, ok: response.ok, responseHeaders: headers_to_object(response.headers) })
76
208
  return response
77
209
  }
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
210
+ const res_result = apply_policy(response.headers, policy, { direction: "response" })
211
+ if (res_result.blocked.length) dbg(`blocked response headers on ${url}: ${res_result.blocked.join(", ")}`)
212
+ finish({ status: response.status, ok: response.ok, responseHeaders: headers_to_object(res_result.headers), blockedResponseHeaders: res_result.blocked })
213
+ if (!res_result.blocked.length) return response
82
214
  const body = await response.clone().blob()
83
- return new Response(body, { status: response.status, statusText: response.statusText, headers: filtered_res })
215
+ return new Response(body, { status: response.status, statusText: response.statusText, headers: res_result.headers })
84
216
  }
85
217
  }
86
218
 
@@ -0,0 +1,37 @@
1
+ // Search engine registry. Built-ins are "google" and "duckduckgo"; register
2
+ // your own with a name and a function that turns a query into a URL.
3
+ // search_engines.register("bing", (q) => `https://www.bing.com/search?q=${encodeURIComponent(q)}`)
4
+ // set_header_policy set the default via init_lithium({ searchEngine: "bing" })
5
+
6
+ const engines = new Map([
7
+ ["google", (q) => `https://www.google.com/search?q=${encodeURIComponent(q)}`],
8
+ ["duckduckgo", (q) => `https://duckduckgo.com/?q=${encodeURIComponent(q)}`],
9
+ ])
10
+
11
+ export const search_engines = {
12
+ list: () => [...engines.keys()],
13
+ has: (name) => engines.has(name),
14
+ register(name, url_builder) {
15
+ if (typeof name !== "string" || !name) throw new TypeError('search_engines.register(name, urlBuilder): name must be a non-empty string')
16
+ if (typeof url_builder !== "function") throw new TypeError(`search_engines.register("${name}", urlBuilder): urlBuilder must be a function (query) => url`)
17
+ engines.set(name, url_builder)
18
+ },
19
+ }
20
+
21
+ // falls back to "google" if the name isn't registered (never throws on lookup;
22
+ // registering a bad one throws above, but using an unknown name shouldn't
23
+ // break a running page over a typo)
24
+ export function build_search_url(query, name) {
25
+ const build = engines.get(name) ?? engines.get("google")
26
+ return build(String(query).trim())
27
+ }
28
+
29
+ // the origin a search actually goes to, used to scope search-engine-only
30
+ // header overrides (see net.js). null if the engine's URL is malformed.
31
+ export function search_origin(name) {
32
+ try {
33
+ return new URL(build_search_url("x", name)).origin
34
+ } catch {
35
+ return null
36
+ }
37
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nivalos/lithium.js",
3
- "version": "1.1.0",
3
+ "version": "1.6.0",
4
4
  "description": "Creating a proxy has never been easier with Lithium.js",
5
5
  "type": "module",
6
6
  "main": "server/index.js",
@@ -10,6 +10,13 @@
10
10
  "exports": {
11
11
  ".": "./server/index.js"
12
12
  },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/Lithium-Foundry/Lithium.js.git"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
13
20
  "scripts": {
14
21
  "start": "node server/start.js",
15
22
  "test": "echo \"no automated tests are bundled, run `npx lithium doctor` to check your install\""