@nivalos/lithium.js 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }
@@ -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
+ }
@@ -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
+ }