@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/README.md ADDED
@@ -0,0 +1,317 @@
1
+ # Lithium.JS
2
+
3
+ ![npm version](https://img.shields.io/npm/v/lithium.js?color=blue)
4
+ ![npm downloads](https://img.shields.io/npm/dw/lithium.js)
5
+ ![license](https://img.shields.io/badge/license-AGPL-purple?color=663366)
6
+ ![node version](https://img.shields.io/badge/node-%3E%3D24.0-brightgreen)
7
+ ![status](https://img.shields.io/badge/status-beta-orange)
8
+ ![proxy engines](https://img.shields.io/badge/proxies-UV%20%7C%20Scramjet-purple)
9
+
10
+ A flexible web proxy framework to make your skid dream a reality.
11
+
12
+ ## Features
13
+
14
+ - **Proxy registry**: Ultraviolet 3.x and Scramjet 2.x built in, `register()` your own
15
+ - **Transport registry**: Epoxy and Libcurl built in, each declaring which interface generation they speak
16
+ - **Compatibility validation at startup**: wrong package version or wrong transport generation throws a clear `LithiumError`, not a proxy that "sort of works" until it doesn't
17
+ - **`lithium doctor`**: one command to check your whole install
18
+ - **Events + history**: `on("navigate", ...)`, `back()`/`forward()`/`reload()`/`search()`, works the same for every proxy
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
21
+ - **Modular design**: clean separation of client and server code, custom backends are first-class
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ npm install lithium.js
27
+ ```
28
+
29
+ Scramjet 2.x is published under the `alpha` tag and Lithium pins the exact
30
+ versions it was built against, so you don't need to install Scramjet yourself.
31
+
32
+ ## Quick Start
33
+
34
+ ### Server Setup
35
+
36
+ ```javascript
37
+ import { create_lithium_server } from "lithium.js";
38
+
39
+ const { server, port } = create_lithium_server({
40
+ staticDir: 'public',
41
+ port: 8080,
42
+ proxy: 'ultraviolet',
43
+ transport: 'epoxy'
44
+ })
45
+
46
+ server.listen(port, () => {
47
+ console.log(`Lithium server running on http://localhost:${port}`)
48
+ })
49
+ ```
50
+
51
+ ** Ensure that you import it relative to your type in package.json.
52
+
53
+ `create_lithium_server` doesn't call `listen` for you, so you can attach your
54
+ own routes to the returned `app` first. Want to just try it? `npm start` runs
55
+ a ready-made server (`PROXY`, `TRANSPORT`, `PORT` and `STATIC_DIR` env vars).
56
+
57
+ ### Client Setup
58
+
59
+ ```javascript
60
+ import { init_lithium, navigate } from "/client/index.js";
61
+
62
+ await init_lithium({
63
+ searchEngine: 'google',
64
+ onReady: () => {
65
+ console.log('Lithium is ready!')
66
+ }
67
+ })
68
+
69
+ navigate('example.com')
70
+ navigate('search query')
71
+ ```
72
+
73
+ When you are linking this script, make sure to add type="module".
74
+
75
+ `init_lithium` loads everything the chosen proxy needs (bare-mux, the Ultraviolet
76
+ bundle, the Scramjet controller, ...) by itself, so you no longer need extra
77
+ `<script>` tags. It rejects if setup fails, so wrap it in try/catch if you want
78
+ to show an error.
79
+
80
+ Pages are shown in an iframe. Give yours the id `proxyFrame` (and optionally put
81
+ it inside an element with id `container`), or Lithium will create one for you.
82
+
83
+ ## Configuration
84
+
85
+ ### Server Options
86
+
87
+ | Option | Type | Default | Description |
88
+ |-----------------------|-----------|-----------------|--------------------------------------------------------------------------------|
89
+ | staticDir | string | `'public'` | Directory for static files (relative to `process.cwd()`, or absolute) |
90
+ | port | number | `8080` | Port, returned from `create_lithium_server` for you to `listen` on |
91
+ | proxy | string | `'ultraviolet'` | Proxy: `'ultraviolet'`, `'scramjet'`, or one you registered (see below) |
92
+ | transport | string | `'epoxy'` | Transport: `'epoxy'`, `'libcurl'`, or one you registered |
93
+ | crossOriginIsolation | boolean | `true` | Sends COOP/COEP headers when the proxy wants them (Scramjet does, UV doesn't) |
94
+ | middleware | function[]| `[]` | `(req, res, next) => ...` functions, run before Lithium's own routes |
95
+ | wisp | object | `{}` | Options merged into the shared wisp-js server, e.g. `{ allow_loopback_ips: true }` |
96
+ | strict | boolean | `true` | `false`: warn about a package version mismatch instead of throwing |
97
+ | quiet | boolean | `false` | Suppress the `[lithium] proxy + transport: ...` startup line |
98
+ | color | boolean | *(auto)* | Force-enable/disable colored console output (auto-detects a TTY otherwise) |
99
+
100
+ An unknown `proxy`/`transport`, an incompatible pair, or a wrong package version throws a `LithiumError` (see below) instead of starting halfway broken.
101
+
102
+ ### Client Options (`init_lithium(options)`)
103
+
104
+ | Option | Type | Default | Description |
105
+ |--------------|----------|------------|------------------------------------------------------------------------|
106
+ | searchEngine | string | `'google'` | `'google'` or `'duckduckgo'` |
107
+ | onReady | function | `null` | Shorthand for `on("ready", ...)` |
108
+ | onUrlChange | function | `null` | Shorthand for `on("navigate", ({url}) => ...)`, works for every proxy |
109
+ | debug | boolean | `false` | Verbose `[lithium debug]` tracing, see "Debugging" below |
110
+ | headers | object | see below | Header policy for the proxied page's `fetch`/XHR, see "Headers" below |
111
+
112
+ ## Usage Examples
113
+
114
+ ### Navigate, search, and move around
115
+
116
+ ```javascript
117
+ navigate('google.com') // url or domain
118
+ navigate('https://example.com')
119
+ search('minecraft') // always a search, even if it looks like a url
120
+
121
+ current_url() // the real url currently shown
122
+ back(); forward(); reload() // act on the proxied page's own history
123
+ ```
124
+
125
+ ### Listen for navigation
126
+
127
+ Works the same way for every proxy, built-in or custom:
128
+
129
+ ```javascript
130
+ on("navigate", ({ url, previousUrl }) => console.log("now at", url))
131
+ on("ready", () => console.log("lithium is ready"))
132
+ ```
133
+
134
+ ### Check current config
135
+
136
+ ```javascript
137
+ console.log(config.proxy, config.transport, config.interface, config.ready)
138
+ ```
139
+
140
+ ### Switch proxy/transport
141
+
142
+ ```javascript
143
+ const { server, port } = create_lithium_server({ proxy: 'scramjet', transport: 'libcurl' })
144
+ ```
145
+
146
+ ## Registry (`proxies`, `transports`)
147
+
148
+ ```javascript
149
+ import { proxies, transports, supports, get_compatibility } from "lithium.js"
150
+
151
+ proxies.list() // ["ultraviolet", "scramjet"]
152
+ transports.list() // ["epoxy", "libcurl"]
153
+ proxies.get("scramjet") // { name, interface, isolation, builtin, transports, packages }
154
+ supports("scramjet", "epoxy") // true
155
+ get_compatibility("ultraviolet", "x") // { supported: false, interface: "bare-mux", reason: "..." }
156
+ ```
157
+
158
+ ### Registering a custom backend
159
+
160
+ ```javascript
161
+ proxies.register("my-proxy", {
162
+ interface: "bare-mux", // or "proxy-transports", or your own transport's interface name
163
+ isolation: false, // send COOP/COEP for this proxy?
164
+ packages: [], // [{ name, major? }] checked at startup
165
+ routes(app) { /* app.get(...) for anything special */ },
166
+ mounts() { return [{ label: "my-proxy", url: "/my-proxy/", dir: someDir, files: ["bundle.js"] }] },
167
+ serviceWorker: `...`, // JS source; Lithium wraps it with skipWaiting/clients.claim
168
+ clientModule: "/my-proxy/client.js", // browser module implementing init/navigate, see client/backends/*.js
169
+ })
170
+
171
+ transports.register("my-transport", {
172
+ interfaces: { "bare-mux": { mount: "/my-transport/", dir: someDir, entry: "index.mjs" } },
173
+ })
174
+ ```
175
+
176
+ `client/backends/ultraviolet.js` and `client/backends/scramjet.js` are the reference implementations of the client module contract (`init(ctx)`, `navigate(url, ctx)`, optional `current_url(ctx)`).
177
+
178
+ ## Errors (`LithiumError`)
179
+
180
+ Every error Lithium throws on purpose has a `.code` you can branch on, plus `.details` with what would actually work:
181
+
182
+ ```javascript
183
+ import { LithiumError } from "lithium.js"
184
+ try {
185
+ create_lithium_server({ proxy: "ultraviolet", transport: "wisp" })
186
+ } catch (err) {
187
+ if (err instanceof LithiumError) console.log(err.code, err.details)
188
+ // "INCOMPATIBLE_TRANSPORT" { proxyInterface: "bare-mux", compatibleTransports: ["epoxy", "libcurl"], ... }
189
+ }
190
+ ```
191
+
192
+ Codes: `UNKNOWN_PROXY`, `UNKNOWN_TRANSPORT`, `INCOMPATIBLE_TRANSPORT`, `PACKAGE_MISSING`, `PACKAGE_VERSION_MISMATCH`, `TRANSPORT_VERSION_MISMATCH`, `BACKEND_EXISTS`, `INVALID_BACKEND`, `INVALID_OPTION`.
193
+
194
+ ## `lithium doctor`
195
+
196
+ ```
197
+ npx lithium doctor [--proxy ultraviolet] [--transport epoxy] [--json] [--no-color]
198
+ ```
199
+
200
+ Checks Node's version, every package's version (and, for epoxy/libcurl, which *generation* is installed — this is the check that would have caught the original UV startup bug), that every file each backend needs is actually on disk, and whether the proxy/transport pair you're about to run is compatible. Exits `1` if it finds a problem, so it's CI-friendly. Colored automatically in a terminal, plain when piped, or force with `--no-color`/`NO_COLOR=1`/`FORCE_COLOR=1`.
201
+
202
+ ## Debugging
203
+
204
+ ```javascript
205
+ await init_lithium({ debug: true }) // or: config.debug = true, any time
206
+ ```
207
+
208
+ Traces every step (script load order, service worker registration, backend init, navigation targets) as `[lithium debug]` lines, and turns on `window.onerror`/`unhandledrejection` logging. Off by default since it's noisy.
209
+
210
+ ## Headers
211
+
212
+ Lithium's Node server never sees the proxied site's raw HTTP (that goes browser → service worker → the wisp tunnel → the real site, as bytes, not parsed HTTP), so header control happens client-side, on the proxied page's own `fetch`/`XHR` calls:
213
+
214
+ ```javascript
215
+ await init_lithium({
216
+ 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
220
+ },
221
+ })
222
+ set_header_policy({ mode: "passthrough" }) // change it any time, no re-init needed
223
+ ```
224
+
225
+ **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
+
227
+ ## Network log
228
+
229
+ ```javascript
230
+ on("request", (entry) => console.log(entry)) // { method, url, status, duration, requestHeaders, responseHeaders, blockedRequestHeaders, blockedResponseHeaders, ... }
231
+ network_log() // everything captured so far (capped at 300 entries)
232
+ clear_network_log()
233
+ ```
234
+
235
+ Same scope as Headers above: `fetch`/`XHR` only, reinstalled fresh on every real navigation (a new page is a new `window`), and only requests made after that page finishes loading. The test app (`lithium-test/`) has a "Network" panel built on this.
236
+
237
+ ## Colored output
238
+
239
+ `server/color.js` is a tiny zero-dependency ANSI helper (`color.green(...)`, etc.) used for `lithium doctor` and the server's own startup/warning/error lines. It auto-detects a TTY and respects `NO_COLOR`/`FORCE_COLOR`; pass `color: false` to `create_lithium_server` or `--no-color` to the CLI to force it off.
240
+
241
+ ## How the transports work (read this if you touch package.json)
242
+
243
+ The transport packages come in two generations that are **not** interchangeable:
244
+
245
+ | Proxy | Interface | epoxy | libcurl |
246
+ |------------------|--------------------|-------|---------|
247
+ | Ultraviolet 3.x | bare-mux | ^2 | ^1 |
248
+ | Scramjet 2.x | proxy-transports | ^3 | ^2 |
249
+
250
+ Mixing them can look like it works and then fail on specific sites. Lithium
251
+ installs both: the normal `@mercuryworkshop/epoxy-transport` and
252
+ `@mercuryworkshop/libcurl-transport` are the Scramjet generation, and the
253
+ `epoxy-transport-bm` / `libcurl-transport-bm` entries in `package.json` are npm
254
+ aliases for the bare-mux generation that Ultraviolet uses. Don't "clean up" the
255
+ aliases or bump their versions to match.
256
+
257
+ Files are served at:
258
+
259
+ | Path | What |
260
+ |------------------------------|----------------------------------------|
261
+ | `/uv/`, `/baremux/` | Ultraviolet + bare-mux (`/uv/uv.config.js` is generated by Lithium; proxied pages live under `/service/`) |
262
+ | `/bm/epoxy/`, `/bm/libcurl/` | transport for Ultraviolet |
263
+ | `/scram/`, `/controller/`, `/utils/` | Scramjet, its controller, its plugins |
264
+ | `/epoxy/`, `/libcurl/` | transport for Scramjet |
265
+ | `/wisp/` | wisp websocket |
266
+
267
+ ## Troubleshooting
268
+
269
+ ### Start here
270
+ Run `npx lithium doctor` first — it checks Node's version, every package version (including which transport *generation* is installed, the single most common source of "it starts then breaks on random sites"), that every file each backend needs is on disk, and whether your proxy/transport pair is compatible.
271
+
272
+ ### Config not loading
273
+ - Check browser console for `[lithium] config:`
274
+ - Ensure your HTML is a `.html` file in `staticDir` (config is injected into `<head>` when the file is served)
275
+
276
+ ### Proxy not working
277
+ - Check if service worker registered: `navigator.serviceWorker.controller`
278
+ - Verify proxy files are accessible in Network tab
279
+ - Look for errors in console, and for `[lithium] ...: expected "..." in ...` lines in the server log
280
+
281
+ ### Scramjet: some sites break, or a warning about cross-origin isolation
282
+ - `crossOriginIsolated` must be `true` in the browser console (needs `localhost` or https)
283
+ - If your own page loads third-party fonts/images and they got blocked, either serve them with CORS/CORP headers or set `crossOriginIsolation: false` (some proxied sites will then break)
284
+
285
+ ### Ultraviolet: "ServiceWorker script evaluation failed"
286
+ - The browser gives no detail, so Lithium's client logs the status of every file the worker imports (look for `[lithium] 404 ...` lines). A 404 next to `<- config.sw` (or `handler`/`bundle`) means the `uv.config.js` being served isn't Lithium's generated one, e.g. a stale copy in your `staticDir` or a caching layer.
287
+
288
+ ### Transport issues
289
+ - Ultraviolet: ensure the BareMux worker is accessible at `/baremux/worker.js`
290
+ - Check WISP connection in DevTools (`/wisp/`)
291
+ - Verify transport files are served (see the table above)
292
+
293
+ ### A site looks broken and you don't know why
294
+ - Turn on `init_lithium({ debug: true })` and reload — see "Debugging" above
295
+ - Open `network_log()` (or the test app's "Network" panel) and check for unexpected `status: 0` entries (the request threw) or headers you're blocking that the site actually needed
296
+ - If you're blocking headers, try `set_header_policy({ mode: "passthrough" })` to rule that out first
297
+
298
+ ## Copyright notice
299
+ ```
300
+ sythora/Platinum: A flexible web proxy framework to make your skid dream a reality.
301
+ Copyright (C) 2026 sythora & nivalos
302
+
303
+ This program is free software: you can redistribute it and/or modify
304
+ it under the terms of the GNU Affero General Public License as
305
+ published by the Free Software Foundation, either version 3 of the
306
+ License, or (at your option) any later version.
307
+
308
+ This program is distributed in the hope that it will be useful,
309
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
310
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
311
+ GNU Affero General Public License for more details.
312
+
313
+ You should have received a copy of the GNU Affero General Public License
314
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
315
+ ```
316
+
317
+ Lithium.js is an updated fork of Platinum.js, this fork updates dependencies, and is the new framework for Lithium (a fork of Utopia)
package/bin/lithium.js ADDED
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+ // lithium doctor [--proxy <name>] [--transport <name>] [--json]
3
+ import { run_doctor, format_doctor } from "../server/doctor.js"
4
+ import { set_color } from "../server/color.js"
5
+
6
+ const args = process.argv.slice(2)
7
+ const flag = (name) => {
8
+ const i = args.indexOf(`--${name}`)
9
+ return i === -1 ? undefined : args[i + 1]
10
+ }
11
+
12
+ const HELP = `usage: lithium doctor [options]
13
+
14
+ Checks your install: package versions (including the two transport
15
+ generations), served files, and whether a proxy/transport pair is compatible.
16
+
17
+ options:
18
+ --proxy <name> proxy to check the configuration for (default: ultraviolet)
19
+ --transport <name> transport to check the configuration for (default: epoxy)
20
+ --json machine-readable output
21
+ --no-color disable colored output (also respects NO_COLOR)
22
+ -h, --help this text
23
+
24
+ exit code is 1 if a problem was found, 0 otherwise.
25
+ `
26
+
27
+ const command = args[0]
28
+ if (!command || command === "-h" || command === "--help" || command === "help") {
29
+ console.log(HELP)
30
+ process.exit(command ? 0 : 1)
31
+ }
32
+ if (command !== "doctor") {
33
+ console.error(`unknown command "${command}"\n\n${HELP}`)
34
+ process.exit(1)
35
+ }
36
+
37
+ if (args.includes("--no-color")) set_color(false)
38
+ const result = await run_doctor({ proxy: flag("proxy"), transport: flag("transport") })
39
+ console.log(args.includes("--json") ? JSON.stringify(result, null, 2) : format_doctor(result))
40
+ process.exit(result.ok ? 0 : 1)
@@ -0,0 +1,53 @@
1
+ // Scramjet 2.x backend for the Lithium client (see ultraviolet.js for the
2
+ // backend module contract).
3
+
4
+ export async function init(ctx) {
5
+ if (!window.crossOriginIsolated) {
6
+ console.warn("[lithium] page is not cross-origin isolated, some proxied sites will break (needs https or localhost, and the server's COOP/COEP headers)")
7
+ }
8
+
9
+ for (const src of ["/scram/scramjet.js", "/controller/controller.api.js", "/utils/scramjet-utils.js"]) {
10
+ await ctx.load_script(src)
11
+ }
12
+ const api = window.$scramjetController
13
+ if (!api?.Controller) throw new Error("scramjet controller global ($scramjetController) is missing")
14
+
15
+ // the transport is a plain object handed straight to the controller
16
+ const { default: Transport } = await import(ctx.transport_url)
17
+
18
+ const controller = new api.Controller({
19
+ serviceworker: ctx.serviceworker,
20
+ transport: new Transport({ wisp: ctx.wisp_url }),
21
+ config: {
22
+ scramjetPath: "/scram/scramjet.js",
23
+ wasmPath: "/scram/scramjet.wasm",
24
+ injectPath: "/controller/controller.inject.js",
25
+ },
26
+ })
27
+ // don't create frames before this resolves or the first navigation can 404
28
+ await controller.wait()
29
+ ctx.state.controller = controller
30
+
31
+ // browsers kill idle service workers after ~30s and scramjet's worker
32
+ // forgets its routes when that happens (later navigations 404 until a
33
+ // reload). a ping resets the idle timer.
34
+ setInterval(() => navigator.serviceWorker.controller?.postMessage("keepalive"), 15000)
35
+
36
+ console.log(`[lithium] scramjet ready (${ctx.transport} over ${ctx.wisp_url})`)
37
+ }
38
+
39
+ export function navigate(url, ctx) {
40
+ // one frame, reused (creating a new one per navigation leaks iframes)
41
+ if (!ctx.state.frame) {
42
+ const utils = window.$scramjetUtils
43
+ ctx.state.frame = ctx.state.controller.createFrame(ctx.iframe(), {
44
+ plugins: [
45
+ // target="_blank" / window.open would otherwise escape the proxy
46
+ new utils.CatchEscapedLinksPlugin(() => new URL(location.href)),
47
+ // scramjet tells us where the page went, no polling needed
48
+ new utils.UrlWatcherPlugin((u) => ctx.report_url(String(u))),
49
+ ],
50
+ })
51
+ }
52
+ ctx.state.frame.go(url) // synchronous
53
+ }
@@ -0,0 +1,30 @@
1
+ // Ultraviolet 3.x backend for the Lithium client.
2
+ // A backend module exports:
3
+ // init(ctx) load scripts, set up the transport
4
+ // navigate(url, ctx) show a real URL (already normalised) in ctx.iframe()
5
+ // current_url(ctx) optional: real URL currently shown (polled)
6
+ // ctx.state is yours to keep things in.
7
+
8
+ export async function init(ctx) {
9
+ // bare-mux carries the transport (a SharedWorker), UV bundle does the rewriting
10
+ await ctx.load_script("/baremux/index.js")
11
+ await ctx.load_script("/uv/uv.bundle.js")
12
+ await ctx.load_script("/uv/uv.config.js")
13
+
14
+ const conn = new BareMux.BareMuxConnection("/baremux/worker.js")
15
+ await conn.setTransport(ctx.transport_url, [{ wisp: ctx.wisp_url }])
16
+ console.log(`[lithium] ultraviolet ready (${ctx.transport} over ${ctx.wisp_url})`)
17
+ }
18
+
19
+ export function navigate(url, ctx) {
20
+ ctx.iframe().src = __uv$config.prefix + __uv$config.encodeUrl(url)
21
+ }
22
+
23
+ // ultraviolet has no url-change hook, so Lithium polls this
24
+ export function current_url(ctx) {
25
+ const frame = ctx.iframe(false)
26
+ const cfg = window.__uv$config
27
+ if (!frame || !cfg) return null
28
+ const path = frame.contentWindow.location.pathname
29
+ return path.startsWith(cfg.prefix) ? cfg.decodeUrl(path.slice(cfg.prefix.length)) : null
30
+ }