@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/server/doctor.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import { dirname, join } from "node:path"
|
|
3
|
+
import { fileURLToPath } from "node:url"
|
|
4
|
+
|
|
5
|
+
import { PROXY_META, TRANSPORT_META, CORE_PACKAGES } from "./meta.js"
|
|
6
|
+
import { check_packages } from "./pkgs.js"
|
|
7
|
+
import { color } from "./color.js"
|
|
8
|
+
|
|
9
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
10
|
+
|
|
11
|
+
// Diagnoses a Lithium install. Never throws: problems are returned as data
|
|
12
|
+
// so it works on exactly the installs that are broken.
|
|
13
|
+
// run_doctor({ proxy?, transport? }) -> { ok, errors, warnings, checks: [...] }
|
|
14
|
+
export async function run_doctor(opts = {}) {
|
|
15
|
+
const checks = []
|
|
16
|
+
const add = (section, level, message, extra = {}) => checks.push({ section, level, message, ...extra })
|
|
17
|
+
const add_findings = (section, findings) => {
|
|
18
|
+
for (const f of findings) {
|
|
19
|
+
const { level, message, ...rest } = f
|
|
20
|
+
add(section, level, message, rest)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ---- environment
|
|
25
|
+
let need = null
|
|
26
|
+
try {
|
|
27
|
+
const engines = JSON.parse(fs.readFileSync(join(HERE, "..", "package.json"), "utf8")).engines?.node
|
|
28
|
+
need = Number(/(\d+)/.exec(engines ?? "")?.[1]) || null
|
|
29
|
+
} catch {}
|
|
30
|
+
const have = Number(process.versions.node.split(".")[0])
|
|
31
|
+
if (need && have < need) {
|
|
32
|
+
add("Environment", "warn", `Node.js ${process.version} (Lithium.js declares node >=${need})`, { code: "NODE_VERSION" })
|
|
33
|
+
} else {
|
|
34
|
+
add("Environment", "ok", `Node.js ${process.version}`)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---- packages
|
|
38
|
+
add_findings("Core packages", check_packages(CORE_PACKAGES.map((p) => ({ ...p, role: "core" }))))
|
|
39
|
+
|
|
40
|
+
for (const [name, meta] of Object.entries(PROXY_META)) {
|
|
41
|
+
add_findings(`${meta.title} (${meta.interface} interface)`, check_packages(meta.packages.map((p) => ({ ...p, role: "proxy" }))))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (const [tname, ifaces] of Object.entries(TRANSPORT_META)) {
|
|
45
|
+
const items = Object.entries(ifaces).map(([iface, m]) => ({
|
|
46
|
+
name: m.package, major: m.major, role: "transport", transport: tname, interface: iface,
|
|
47
|
+
}))
|
|
48
|
+
const findings = check_packages(items)
|
|
49
|
+
// label which generation each line is, that's the point of this section
|
|
50
|
+
findings.forEach((f, i) => { if (f.level !== "error") f.message += ` (${tname}, ${items[i].interface} generation)` })
|
|
51
|
+
add_findings("Transports", findings)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---- served files (needs the packages to be importable)
|
|
55
|
+
let reg = null
|
|
56
|
+
try {
|
|
57
|
+
reg = await import("./registry.js")
|
|
58
|
+
} catch (err) {
|
|
59
|
+
const first = String(err?.message ?? err).split("\n")[0]
|
|
60
|
+
add("Files", "error", `couldn't load Lithium's backends: ${first}`, {
|
|
61
|
+
code: "BACKENDS_LOAD_FAILED", hint: "a package is missing or broken, fix the errors above and run `npm install`",
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const wanted_proxy = opts.proxy ?? "ultraviolet"
|
|
66
|
+
const wanted_transport = opts.transport ?? "epoxy"
|
|
67
|
+
|
|
68
|
+
if (reg) {
|
|
69
|
+
const seen = new Set()
|
|
70
|
+
for (const p of Object.keys(PROXY_META)) {
|
|
71
|
+
for (const t of Object.keys(TRANSPORT_META)) {
|
|
72
|
+
let mounts
|
|
73
|
+
try { mounts = reg.mounts_for(reg.resolve_backend(p, t)) } catch (err) {
|
|
74
|
+
add("Files", "error", `${p} + ${t}: ${err.message}`, { code: err.code ?? "MOUNT_FAILED" })
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
for (const m of mounts) {
|
|
78
|
+
const key = `${m.url}|${m.dir}`
|
|
79
|
+
if (seen.has(key)) continue
|
|
80
|
+
seen.add(key)
|
|
81
|
+
const missing = m.files.filter((f) => !fs.existsSync(join(m.dir, f)))
|
|
82
|
+
if (missing.length) add("Files", "error", `${m.url} (${m.label}) is missing ${missing.join(", ")} in ${m.dir}`, { code: "FILES_MISSING", path: m.dir, missing })
|
|
83
|
+
else add("Files", "ok", `${m.url} (${m.label})`)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---- the configuration you're about to run
|
|
89
|
+
const compat = reg.get_compatibility(wanted_proxy, wanted_transport)
|
|
90
|
+
const section = `Configuration (proxy=${wanted_proxy}, transport=${wanted_transport})`
|
|
91
|
+
if (!compat.supported) {
|
|
92
|
+
add(section, "error", compat.reason, { code: "INCOMPATIBLE_TRANSPORT" })
|
|
93
|
+
} else {
|
|
94
|
+
add(section, "ok", `${wanted_proxy} and ${wanted_transport} are compatible (${compat.interface} interface)`)
|
|
95
|
+
const isolated = reg.proxies.get(wanted_proxy)?.isolation
|
|
96
|
+
add(section, "info", isolated
|
|
97
|
+
? "cross-origin isolation: on (COOP/COEP headers are sent; your own page must not load third-party assets without CORS/CORP)"
|
|
98
|
+
: "cross-origin isolation: off")
|
|
99
|
+
add(section, "info", "service workers need https or localhost; Scramjet also needs the page to be cross-origin isolated")
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const errors = checks.filter((c) => c.level === "error").length
|
|
104
|
+
const warnings = checks.filter((c) => c.level === "warn").length
|
|
105
|
+
return { ok: errors === 0, errors, warnings, checks }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const ICON = { ok: "✓", warn: "!", error: "✗", info: "•" }
|
|
109
|
+
const PAINT = { ok: color.green, warn: color.yellow, error: color.red, info: color.gray }
|
|
110
|
+
|
|
111
|
+
export function format_doctor(result) {
|
|
112
|
+
const lines = ["", "Lithium doctor", ""]
|
|
113
|
+
let section = null
|
|
114
|
+
for (const c of result.checks) {
|
|
115
|
+
if (c.section !== section) {
|
|
116
|
+
if (section !== null) lines.push("")
|
|
117
|
+
lines.push(c.section)
|
|
118
|
+
section = c.section
|
|
119
|
+
}
|
|
120
|
+
const paint = PAINT[c.level] ?? ((x) => x)
|
|
121
|
+
lines.push(` ${paint(ICON[c.level] ?? "?")} ${c.message}`)
|
|
122
|
+
if ((c.level === "error" || c.level === "warn") && c.hint) lines.push(color.dim(` → ${c.hint}`))
|
|
123
|
+
}
|
|
124
|
+
lines.push("")
|
|
125
|
+
lines.push(
|
|
126
|
+
result.ok
|
|
127
|
+
? color.green(`No problems found${result.warnings ? ` (${result.warnings} warning${result.warnings === 1 ? "" : "s"})` : ""}.`)
|
|
128
|
+
: color.red(`${result.errors} problem${result.errors === 1 ? "" : "s"} found${result.warnings ? `, ${result.warnings} warning${result.warnings === 1 ? "" : "s"}` : ""}.`)
|
|
129
|
+
)
|
|
130
|
+
lines.push("")
|
|
131
|
+
return lines.join("\n")
|
|
132
|
+
}
|
package/server/errors.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Every error Lithium throws on purpose is a LithiumError, so you can branch
|
|
2
|
+
// on `err.code` instead of parsing message strings.
|
|
3
|
+
//
|
|
4
|
+
// try { create_lithium_server({ proxy: "nope" }) }
|
|
5
|
+
// catch (e) { if (e.code === "UNKNOWN_PROXY") console.log(e.details.available) }
|
|
6
|
+
//
|
|
7
|
+
// Codes: UNKNOWN_PROXY, UNKNOWN_TRANSPORT, INCOMPATIBLE_TRANSPORT,
|
|
8
|
+
// PACKAGE_MISSING, PACKAGE_VERSION_MISMATCH, TRANSPORT_VERSION_MISMATCH,
|
|
9
|
+
// BACKEND_EXISTS, INVALID_BACKEND, INVALID_OPTION
|
|
10
|
+
export class LithiumError extends Error {
|
|
11
|
+
constructor(code, message, details = {}) {
|
|
12
|
+
super(message)
|
|
13
|
+
this.name = "LithiumError"
|
|
14
|
+
this.code = code
|
|
15
|
+
this.details = details
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
toJSON() {
|
|
19
|
+
return { name: this.name, code: this.code, message: this.message, details: this.details }
|
|
20
|
+
}
|
|
21
|
+
}
|
package/server/index.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import express from "express"
|
|
2
|
+
import { createServer } from "node:http"
|
|
3
|
+
import { join, dirname, resolve, sep } from "node:path"
|
|
4
|
+
import { fileURLToPath } from "node:url"
|
|
5
|
+
import fs from "node:fs"
|
|
6
|
+
import { load } from "cheerio"
|
|
7
|
+
import { server as wisp } from "@mercuryworkshop/wisp-js/server"
|
|
8
|
+
|
|
9
|
+
import { proxies, transports, supports, get_compatibility, resolve_backend, mounts_for, packages_for } from "./registry.js"
|
|
10
|
+
import { check_packages, find_package } from "./pkgs.js"
|
|
11
|
+
import { LithiumError } from "./errors.js"
|
|
12
|
+
import { run_doctor } from "./doctor.js"
|
|
13
|
+
import { color, set_color } from "./color.js"
|
|
14
|
+
|
|
15
|
+
export { proxies, transports, supports, get_compatibility, LithiumError, run_doctor }
|
|
16
|
+
|
|
17
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
18
|
+
|
|
19
|
+
// generated service worker for custom proxies (built-ins use client/sw.js)
|
|
20
|
+
const sw_source = (custom) => `// generated by lithium for the "${custom.name}" proxy
|
|
21
|
+
self.addEventListener("install", () => self.skipWaiting())
|
|
22
|
+
self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()))
|
|
23
|
+
${custom.serviceWorker}
|
|
24
|
+
`
|
|
25
|
+
|
|
26
|
+
// main function
|
|
27
|
+
// proxy "ultraviolet" | "scramjet" | anything you registered
|
|
28
|
+
// transport "epoxy" | "libcurl" | anything you registered
|
|
29
|
+
// staticDir, port your frontend folder / the port to listen on
|
|
30
|
+
// crossOriginIsolation send COOP/COEP (only for proxies that want it)
|
|
31
|
+
// middleware [(req, res, next) => ...] runs before Lithium's routes
|
|
32
|
+
// wisp options for the wisp server, e.g. { allow_loopback_ips: true }
|
|
33
|
+
// (wisp is one shared instance, so this is process-wide)
|
|
34
|
+
// strict false: warn about version problems instead of throwing
|
|
35
|
+
// quiet no startup log line
|
|
36
|
+
export function create_lithium_server(opts = {}) {
|
|
37
|
+
const static_dir = opts.staticDir || "public"
|
|
38
|
+
const port = opts.port || 8080
|
|
39
|
+
const proxy = opts.proxy || "ultraviolet"
|
|
40
|
+
const transport = opts.transport || "epoxy"
|
|
41
|
+
const isolation = opts.crossOriginIsolation !== false
|
|
42
|
+
const strict = opts.strict !== false
|
|
43
|
+
const quiet = opts.quiet === true
|
|
44
|
+
if (opts.color != null) set_color(Boolean(opts.color))
|
|
45
|
+
|
|
46
|
+
const middleware = opts.middleware ?? []
|
|
47
|
+
if (!Array.isArray(middleware) || middleware.some((m) => typeof m !== "function")) {
|
|
48
|
+
throw new LithiumError("INVALID_OPTION", "middleware must be an array of (req, res, next) functions", { option: "middleware" })
|
|
49
|
+
}
|
|
50
|
+
if (opts.wisp != null && (typeof opts.wisp !== "object" || Array.isArray(opts.wisp))) {
|
|
51
|
+
throw new LithiumError("INVALID_OPTION", "wisp must be an object of wisp-js server options", { option: "wisp" })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Throws UNKNOWN_PROXY / UNKNOWN_TRANSPORT / INCOMPATIBLE_TRANSPORT
|
|
55
|
+
const backend = resolve_backend(proxy, transport)
|
|
56
|
+
|
|
57
|
+
// Wrong package generations "sort of work" and then die on random sites,
|
|
58
|
+
// so catch them at startup with an error that says what to do.
|
|
59
|
+
const findings = check_packages(packages_for(backend))
|
|
60
|
+
const bad = findings.filter((f) => f.level === "error")
|
|
61
|
+
if (bad.length) {
|
|
62
|
+
if (strict) {
|
|
63
|
+
const { message, hint, ...details } = bad[0]
|
|
64
|
+
throw new LithiumError(bad[0].code, hint ? `${message} (${hint})` : message, { ...details, all: bad })
|
|
65
|
+
}
|
|
66
|
+
for (const f of bad) console.warn(color.yellow(`[lithium] ${f.message}`))
|
|
67
|
+
}
|
|
68
|
+
for (const f of findings.filter((x) => x.level === "warn")) if (!quiet) console.warn(color.yellow(`[lithium] ${f.message}`))
|
|
69
|
+
|
|
70
|
+
if (opts.wisp) Object.assign(wisp.options, opts.wisp)
|
|
71
|
+
|
|
72
|
+
// absolute or relative, resolve() handles both (and Windows drive letters)
|
|
73
|
+
const static_path = resolve(process.cwd(), static_dir)
|
|
74
|
+
const client_path = join(__dirname, "..", "client")
|
|
75
|
+
|
|
76
|
+
const app = express()
|
|
77
|
+
const server = createServer(app)
|
|
78
|
+
|
|
79
|
+
for (const fn of middleware) app.use(fn)
|
|
80
|
+
|
|
81
|
+
// COOP/COEP make the page cross-origin isolated. Scramjet needs it (it
|
|
82
|
+
// passes the isolation on to every proxied site); Ultraviolet doesn't want it.
|
|
83
|
+
const isolated = Boolean(backend.proxy.isolation && isolation)
|
|
84
|
+
if (isolated) {
|
|
85
|
+
app.use((_req, res, next) => {
|
|
86
|
+
res.setHeader("Cross-Origin-Opener-Policy", "same-origin")
|
|
87
|
+
res.setHeader("Cross-Origin-Embedder-Policy", "require-corp")
|
|
88
|
+
next()
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
app.use("/client/", express.static(client_path))
|
|
93
|
+
|
|
94
|
+
// sw.js has to be served from the root so its scope covers the whole site.
|
|
95
|
+
// The client registers it as /sw.js?proxy=<name>; built-in proxies get the
|
|
96
|
+
// shared client/sw.js, custom ones a generated worker.
|
|
97
|
+
const custom_sw = backend.proxy.builtin ? null : sw_source(backend.proxy)
|
|
98
|
+
app.get("/sw.js", (_req, res) => {
|
|
99
|
+
res.setHeader("Service-Worker-Allowed", "/")
|
|
100
|
+
if (custom_sw) res.type("js").send(custom_sw)
|
|
101
|
+
else res.sendFile(join(client_path, "sw.js"))
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// routes first (e.g. generated uv.config.js), so they win over static files
|
|
105
|
+
backend.proxy.routes?.(app)
|
|
106
|
+
|
|
107
|
+
// static folders. A missing file is reported once, here, instead of
|
|
108
|
+
// turning into a mystery 404 in the browser later.
|
|
109
|
+
const problems = []
|
|
110
|
+
let mounts
|
|
111
|
+
try {
|
|
112
|
+
mounts = mounts_for(backend)
|
|
113
|
+
} catch (err) {
|
|
114
|
+
throw new LithiumError("FILES_MISSING", `couldn't locate the files for ${proxy} + ${transport}: ${err.message}`, {
|
|
115
|
+
proxy, transport, cause: String(err?.message ?? err), hint: "run `npx lithium doctor`",
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
for (const m of mounts) {
|
|
119
|
+
app.use(m.url, express.static(m.dir))
|
|
120
|
+
for (const f of m.files) {
|
|
121
|
+
if (!fs.existsSync(join(m.dir, f))) {
|
|
122
|
+
problems.push({ code: "FILES_MISSING", label: m.label, file: f, dir: m.dir })
|
|
123
|
+
console.error(color.red(`[lithium] ${m.label}: expected "${f}" in ${m.dir} but it isn't there`))
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --- config injection ------------------------------------------------------
|
|
129
|
+
// The client reads window.__LITHIUM_CONFIG__ to know which proxy/transport
|
|
130
|
+
// the server was started with.
|
|
131
|
+
const impl = backend.impl
|
|
132
|
+
const injected = {
|
|
133
|
+
proxy,
|
|
134
|
+
transport,
|
|
135
|
+
interface: backend.interface,
|
|
136
|
+
transportUrl: impl.mount + (impl.entry ?? "index.mjs"),
|
|
137
|
+
...(backend.proxy.builtin ? {} : { clientModule: backend.proxy.clientModule }),
|
|
138
|
+
isolated,
|
|
139
|
+
}
|
|
140
|
+
const config_script = "window.__LITHIUM_CONFIG__=" + JSON.stringify(injected).replace(/</g, "\\u003c") + ";"
|
|
141
|
+
|
|
142
|
+
function inject_config(html) {
|
|
143
|
+
try {
|
|
144
|
+
const $ = load(html)
|
|
145
|
+
const tag = `<script>${config_script}</script>`
|
|
146
|
+
if ($("head").length) $("head").prepend(tag)
|
|
147
|
+
else if ($("body").length) $("body").prepend(tag)
|
|
148
|
+
else return tag + html
|
|
149
|
+
return $.html()
|
|
150
|
+
} catch (err) {
|
|
151
|
+
console.error("[lithium] cheerio injection failed:", err)
|
|
152
|
+
return html
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// map a request path to an .html file inside static_path, or null.
|
|
157
|
+
// Never returns anything outside static_path (no ../ tricks).
|
|
158
|
+
function html_file_for(req_path) {
|
|
159
|
+
let rel
|
|
160
|
+
try {
|
|
161
|
+
rel = decodeURIComponent(req_path)
|
|
162
|
+
} catch {
|
|
163
|
+
return null
|
|
164
|
+
}
|
|
165
|
+
if (rel.endsWith("/")) rel += "index.html"
|
|
166
|
+
if (!rel.endsWith(".html")) return null
|
|
167
|
+
|
|
168
|
+
const full = resolve(static_path, "." + rel)
|
|
169
|
+
if (full !== static_path && !full.startsWith(static_path + sep)) return null
|
|
170
|
+
try {
|
|
171
|
+
return fs.statSync(full).isFile() ? full : null
|
|
172
|
+
} catch {
|
|
173
|
+
return null
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// parsing HTML on every request is wasteful, so cache by mtime
|
|
178
|
+
const html_cache = new Map()
|
|
179
|
+
function read_html(file) {
|
|
180
|
+
const mtime = fs.statSync(file).mtimeMs
|
|
181
|
+
const hit = html_cache.get(file)
|
|
182
|
+
if (hit && hit.mtime === mtime) return hit.html
|
|
183
|
+
const html = inject_config(fs.readFileSync(file, "utf8"))
|
|
184
|
+
html_cache.set(file, { mtime, html })
|
|
185
|
+
return html
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
app.use((req, res, next) => {
|
|
189
|
+
if (req.method !== "GET") return next()
|
|
190
|
+
const file = html_file_for(req.path)
|
|
191
|
+
if (!file) return next()
|
|
192
|
+
try {
|
|
193
|
+
return res.type("html").send(read_html(file))
|
|
194
|
+
} catch (err) {
|
|
195
|
+
console.error("[lithium] failed to read/inject file:", err)
|
|
196
|
+
next()
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
// everything else in the static dir (js, css, images, ...)
|
|
201
|
+
app.use(express.static(static_path))
|
|
202
|
+
|
|
203
|
+
// nothing in static dir matched "/" -> show that we're alive
|
|
204
|
+
app.get("/", (_req, res) => {
|
|
205
|
+
res.json({ msg: "lithium server running", proxy, transport, staticDir: static_path })
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
// wisp lives at /wisp/ and nowhere else
|
|
209
|
+
server.on("upgrade", (req, sock, head) => {
|
|
210
|
+
let pathname = null
|
|
211
|
+
try {
|
|
212
|
+
pathname = new URL(req.url ?? "/", "http://localhost").pathname
|
|
213
|
+
} catch {}
|
|
214
|
+
|
|
215
|
+
if (pathname === "/wisp/" && req.headers.upgrade?.toLowerCase() === "websocket") {
|
|
216
|
+
req.url = pathname
|
|
217
|
+
wisp.routeRequest(req, sock, head)
|
|
218
|
+
} else if (server.listenerCount("upgrade") === 1) {
|
|
219
|
+
// only close it if nobody else (e.g. your own ws handler) is listening
|
|
220
|
+
sock.destroy()
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
const info = {
|
|
225
|
+
proxy,
|
|
226
|
+
transport,
|
|
227
|
+
interface: backend.interface,
|
|
228
|
+
isolation: isolated,
|
|
229
|
+
packages: packages_for(backend).map((p) => ({ name: p.name, version: find_package(p.name)?.version ?? null })),
|
|
230
|
+
problems,
|
|
231
|
+
}
|
|
232
|
+
if (!quiet) {
|
|
233
|
+
const pkgs = info.packages.map((p) => `${p.name}@${color.dim(p.version ?? "?")}`).join(", ")
|
|
234
|
+
console.log(color.green(`[lithium] ${proxy} + ${transport}`) + color.dim(` (${backend.interface} interface${isolated ? ", cross-origin isolated" : ""}): `) + pkgs)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return { app, server, port, backend: info }
|
|
238
|
+
}
|
package/server/meta.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Plain data about the built-in backends. No imports of the proxy packages
|
|
2
|
+
// here on purpose: `lithium doctor` uses this to diagnose an install where
|
|
3
|
+
// those packages are missing or broken, so it has to load no matter what.
|
|
4
|
+
|
|
5
|
+
// Two incompatible generations of transport packages exist:
|
|
6
|
+
// "bare-mux" used by Ultraviolet 3.x (epoxy ^2, libcurl ^1)
|
|
7
|
+
// "proxy-transports" used by Scramjet 2.x (epoxy ^3, libcurl ^2)
|
|
8
|
+
// A proxy speaks exactly one interface and can only use a transport that
|
|
9
|
+
// ships that interface.
|
|
10
|
+
|
|
11
|
+
export const PROXY_META = {
|
|
12
|
+
ultraviolet: {
|
|
13
|
+
title: "Ultraviolet",
|
|
14
|
+
interface: "bare-mux",
|
|
15
|
+
// send COOP/COEP headers (cross-origin isolation)? Scramjet needs it.
|
|
16
|
+
// Ultraviolet's service-worker responses don't opt in to COEP, so it
|
|
17
|
+
// would be blocked inside an isolated page.
|
|
18
|
+
isolation: false,
|
|
19
|
+
packages: [
|
|
20
|
+
{ name: "@titaniumnetwork-dev/ultraviolet", major: 3 },
|
|
21
|
+
{ name: "@mercuryworkshop/bare-mux", major: 2 },
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
scramjet: {
|
|
25
|
+
title: "Scramjet",
|
|
26
|
+
interface: "proxy-transports",
|
|
27
|
+
isolation: true,
|
|
28
|
+
// pinned = the exact version Lithium was built and tested against. A
|
|
29
|
+
// different version is a warning (0.x releases break in minor bumps).
|
|
30
|
+
packages: [
|
|
31
|
+
{ name: "@mercuryworkshop/scramjet", major: 2, pinned: "2.0.67-alpha.2" },
|
|
32
|
+
{ name: "@mercuryworkshop/scramjet-controller", major: 0, pinned: "0.0.14" },
|
|
33
|
+
{ name: "@mercuryworkshop/scramjet-utils", major: 0, pinned: "0.0.3" },
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// `package` is the npm install name (the *-bm ones are npm aliases, see
|
|
39
|
+
// package.json), `major` the major version that speaks that interface.
|
|
40
|
+
export const TRANSPORT_META = {
|
|
41
|
+
epoxy: {
|
|
42
|
+
"bare-mux": { package: "epoxy-transport-bm", major: 2 },
|
|
43
|
+
"proxy-transports": { package: "@mercuryworkshop/epoxy-transport", major: 3 },
|
|
44
|
+
},
|
|
45
|
+
libcurl: {
|
|
46
|
+
"bare-mux": { package: "libcurl-transport-bm", major: 1 },
|
|
47
|
+
"proxy-transports": { package: "@mercuryworkshop/libcurl-transport", major: 2 },
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const CORE_PACKAGES = [
|
|
52
|
+
{ name: "express", major: 5 },
|
|
53
|
+
{ name: "cheerio" },
|
|
54
|
+
{ name: "@mercuryworkshop/wisp-js" },
|
|
55
|
+
]
|
package/server/pkgs.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import { dirname, join, basename } from "node:path"
|
|
3
|
+
import { fileURLToPath } from "node:url"
|
|
4
|
+
import { TRANSPORT_META } from "./meta.js"
|
|
5
|
+
|
|
6
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
7
|
+
|
|
8
|
+
// Find an installed package the way Node would (nested node_modules first,
|
|
9
|
+
// then hoisted ones), but by reading its package.json directly. That works
|
|
10
|
+
// for npm aliases and for packages whose "exports" hide package.json, and it
|
|
11
|
+
// never imports the package.
|
|
12
|
+
export function find_package(name, from = HERE) {
|
|
13
|
+
let dir = from
|
|
14
|
+
for (;;) {
|
|
15
|
+
if (basename(dir) !== "node_modules") {
|
|
16
|
+
const pj = join(dir, "node_modules", ...name.split("/"), "package.json")
|
|
17
|
+
if (fs.existsSync(pj)) {
|
|
18
|
+
let version = null
|
|
19
|
+
try { version = JSON.parse(fs.readFileSync(pj, "utf8")).version ?? null } catch {}
|
|
20
|
+
return { name, dir: dirname(pj), version }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const up = dirname(dir)
|
|
24
|
+
if (up === dir) return null
|
|
25
|
+
dir = up
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function major_of(version) {
|
|
30
|
+
const m = /^(\d+)\./.exec(version ?? "")
|
|
31
|
+
return m ? Number(m[1]) : null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// which interface generation does this installed transport major belong to?
|
|
35
|
+
function generation_of(transport, major) {
|
|
36
|
+
const meta = TRANSPORT_META[transport]
|
|
37
|
+
if (!meta) return null
|
|
38
|
+
for (const [iface, m] of Object.entries(meta)) if (m.major === major) return iface
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// items: [{ name, major?, pinned?, role: "proxy"|"transport"|"core",
|
|
43
|
+
// transport?, interface? }]
|
|
44
|
+
// returns one finding per item: { level: "ok"|"warn"|"error", code?, package,
|
|
45
|
+
// installed, message, ...details }
|
|
46
|
+
export function check_packages(items) {
|
|
47
|
+
return items.map((item) => {
|
|
48
|
+
const found = find_package(item.name)
|
|
49
|
+
const base = { package: item.name, role: item.role }
|
|
50
|
+
|
|
51
|
+
if (!found) {
|
|
52
|
+
return {
|
|
53
|
+
...base, level: "error", code: "PACKAGE_MISSING", installed: null,
|
|
54
|
+
message: `${item.name} is not installed`,
|
|
55
|
+
hint: "run `npm install`",
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const installed = found.version
|
|
59
|
+
const found_major = major_of(installed)
|
|
60
|
+
|
|
61
|
+
if (item.major != null && found_major !== item.major) {
|
|
62
|
+
if (item.role === "transport") {
|
|
63
|
+
const generation = generation_of(item.transport, found_major)
|
|
64
|
+
return {
|
|
65
|
+
...base, level: "error", code: "TRANSPORT_VERSION_MISMATCH",
|
|
66
|
+
installed, expected: `${item.major}.x`, interface: item.interface,
|
|
67
|
+
detected: generation ? `${generation} generation` : `major ${found_major}`,
|
|
68
|
+
path: found.dir,
|
|
69
|
+
message:
|
|
70
|
+
`${item.name} ${installed} is installed but the ${item.interface} interface needs ${item.major}.x` +
|
|
71
|
+
(generation ? ` (${installed} is the ${generation} generation)` : ""),
|
|
72
|
+
hint: "don't change the transport package versions in package.json; see the README's transport section",
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
...base, level: "error", code: "PACKAGE_VERSION_MISMATCH",
|
|
77
|
+
installed, expected: `${item.major}.x`, path: found.dir,
|
|
78
|
+
message: `${item.name} ${installed} is installed but Lithium needs ${item.major}.x`,
|
|
79
|
+
hint: "reinstall lithium.js so its pinned dependency versions are used",
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (item.pinned && installed !== item.pinned) {
|
|
84
|
+
return {
|
|
85
|
+
...base, level: "warn", code: "UNTESTED_VERSION", installed, expected: item.pinned,
|
|
86
|
+
message: `${item.name} ${installed} is installed, Lithium was tested with ${item.pinned}`,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { ...base, level: "ok", installed, message: `${item.name} ${installed ?? "(unknown version)"}` }
|
|
90
|
+
})
|
|
91
|
+
}
|