@termfleet/lucarne 0.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 +56 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +148 -0
- package/package.json +27 -0
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# @termfleet/lucarne
|
|
2
|
+
|
|
3
|
+
The **optional bridge** between [`lucarne`](https://www.npmjs.com/package/lucarne)
|
|
4
|
+
(a standalone browser-session engine) and a **termfleet** console.
|
|
5
|
+
|
|
6
|
+
termfleet does not depend on lucarne, and lucarne knows nothing of termfleet —
|
|
7
|
+
this small process is the *only* thing that speaks both. It presents a running
|
|
8
|
+
lucarne daemon to a termfleet console as a **bare-pointer provider** whose windows
|
|
9
|
+
are the session **portholes**. Browser sessions then appear on the canvas next to
|
|
10
|
+
your terminals, reachable from your phone through the console's existing tunnel.
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
termfleet console ──proxy──▶ @termfleet/lucarne ──proxy──▶ lucarne daemon
|
|
14
|
+
(you, remote) (the bridge) (browsers, local)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Run
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
# 1. a lucarne daemon (browsers live here, on your machine)
|
|
21
|
+
npx lucarne serve
|
|
22
|
+
|
|
23
|
+
# 2. the bridge (registers with your console, reflects sessions as windows)
|
|
24
|
+
LUCARNE_URL=http://127.0.0.1:7800 \
|
|
25
|
+
TERMFLEET_CONSOLE_URL=http://127.0.0.1:7373 \
|
|
26
|
+
npx @termfleet/lucarne
|
|
27
|
+
|
|
28
|
+
# 3. create a browser session — it shows up as a window in the console
|
|
29
|
+
npx lucarne create -b native -p work
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## What it serves
|
|
33
|
+
|
|
34
|
+
- `GET /healthz` — the termfleet identity gate (`{ ok, provider: "lucarne" }`)
|
|
35
|
+
- `GET /api/mirror/snapshot` — one iframe window per lucarne session
|
|
36
|
+
- `/sessions/:id/view*` — reverse-proxied to lucarne's porthole (the bridge injects
|
|
37
|
+
the `LUCARNE_TOKEN`, so it never appears in the browser)
|
|
38
|
+
|
|
39
|
+
## Env
|
|
40
|
+
|
|
41
|
+
| var | default | purpose |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| `LUCARNE_URL` | `http://127.0.0.1:7800` | the lucarne daemon |
|
|
44
|
+
| `LUCARNE_TOKEN` | — | lucarne bearer token (injected when proxying) |
|
|
45
|
+
| `TERMFLEET_CONSOLE_URL` | `http://127.0.0.1:7373` | console to self-register with |
|
|
46
|
+
| `BRIDGE_HOST` / `BRIDGE_PORT` | `127.0.0.1` / `7950` | bind address |
|
|
47
|
+
| `BRIDGE_PUBLIC_URL` | `http://HOST:PORT` | the origin it registers as |
|
|
48
|
+
|
|
49
|
+
## Boundaries
|
|
50
|
+
|
|
51
|
+
This package depends on neither termfleet core nor lucarne at build time (it talks
|
|
52
|
+
to both over HTTP). It is **optional** — uninstall it and termfleet is terminals-only,
|
|
53
|
+
lucarne is standalone, and nothing breaks. Drive remains local (an agent hits
|
|
54
|
+
lucarne's CDP on the same host); only the *view* crosses the tunnel.
|
|
55
|
+
|
|
56
|
+
MIT © Aaron Volter
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @termfleet/lucarne — the optional bridge. Presents a running lucarne daemon to
|
|
3
|
+
// a termfleet console as a bare-pointer provider whose windows are the session
|
|
4
|
+
// portholes. termfleet never imports lucarne; lucarne never imports termfleet;
|
|
5
|
+
// THIS is the only process that speaks both.
|
|
6
|
+
//
|
|
7
|
+
// • GET /healthz → identity gate ({ ok, provider })
|
|
8
|
+
// • GET /api/mirror/snapshot → one iframe window per lucarne session
|
|
9
|
+
// • /sessions/:id/view* → reverse-proxied to lucarne's porthole
|
|
10
|
+
// (so the console can proxy it over the tunnel)
|
|
11
|
+
import http from "node:http";
|
|
12
|
+
const LUCARNE_URL = process.env.LUCARNE_URL ?? "http://127.0.0.1:7800";
|
|
13
|
+
const LUCARNE_TOKEN = process.env.LUCARNE_TOKEN;
|
|
14
|
+
const CONSOLE_URL = process.env.TERMFLEET_CONSOLE_URL ?? "http://127.0.0.1:7373";
|
|
15
|
+
const HOST = process.env.BRIDGE_HOST ?? "127.0.0.1";
|
|
16
|
+
const PORT = Number(process.env.BRIDGE_PORT ?? 7950);
|
|
17
|
+
const PUBLIC_URL = process.env.BRIDGE_PUBLIC_URL ?? `http://${HOST}:${PORT}`;
|
|
18
|
+
const PROVIDER_KEY = encodeURIComponent(PUBLIC_URL);
|
|
19
|
+
const windowIds = new Map();
|
|
20
|
+
let nextWindowId = 1;
|
|
21
|
+
let revision = 0;
|
|
22
|
+
async function lucarne(path) {
|
|
23
|
+
const headers = {};
|
|
24
|
+
if (LUCARNE_TOKEN)
|
|
25
|
+
headers["authorization"] = `Bearer ${LUCARNE_TOKEN}`;
|
|
26
|
+
const res = await fetch(LUCARNE_URL + path, { headers });
|
|
27
|
+
return res.json();
|
|
28
|
+
}
|
|
29
|
+
function makeWindow(sessionId, name) {
|
|
30
|
+
let wid = windowIds.get(sessionId);
|
|
31
|
+
if (wid === undefined) {
|
|
32
|
+
wid = nextWindowId++;
|
|
33
|
+
windowIds.set(sessionId, wid);
|
|
34
|
+
}
|
|
35
|
+
const i = wid - 1, col = i % 2, row = Math.floor(i / 2);
|
|
36
|
+
const W = 960, H = 680, gx = 40, gy = 40;
|
|
37
|
+
const left = 40 + col * (W + gx), top = 40 + row * (H + gy);
|
|
38
|
+
const tbH = 30;
|
|
39
|
+
return {
|
|
40
|
+
id: wid,
|
|
41
|
+
name,
|
|
42
|
+
windowKind: "iframe",
|
|
43
|
+
iframe: {
|
|
44
|
+
src: `/sessions/${sessionId}/view/`,
|
|
45
|
+
// load through the console proxy → this bridge → lucarne
|
|
46
|
+
resolvedSrc: `/providers/${PROVIDER_KEY}/sessions/${sessionId}/view/`,
|
|
47
|
+
},
|
|
48
|
+
bounds: { left, top, right: left + W, bottom: top + H, width: W, height: H },
|
|
49
|
+
chrome: {
|
|
50
|
+
id: wid,
|
|
51
|
+
title: name,
|
|
52
|
+
titlebar: { left, top, width: W, height: tbH },
|
|
53
|
+
closeButton: { left: left + W - 30, top: top + 8, width: 16, height: 16 },
|
|
54
|
+
minimizeButton: { left: left + W - 54, top: top + 8, width: 16, height: 16 },
|
|
55
|
+
fullscreenButton: { left: left + W - 78, top: top + 8, width: 16, height: 16 },
|
|
56
|
+
terminalViewport: { left, top: top + tbH, width: W, height: H - tbH },
|
|
57
|
+
textArea: { left, top: top + tbH, width: W, height: H - tbH },
|
|
58
|
+
scrollbar: { left: left + W - 10, top: top + tbH, width: 10, height: H - tbH },
|
|
59
|
+
},
|
|
60
|
+
terminalArea: { left, top: top + tbH, width: W, height: H - tbH },
|
|
61
|
+
terminalSize: { columns: 120, rows: 32 },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
async function snapshot() {
|
|
65
|
+
const sessions = (await lucarne("/sessions").catch(() => []));
|
|
66
|
+
const windows = sessions.map((s) => makeWindow(s.id, `${s.id} (${s.backend})`));
|
|
67
|
+
return {
|
|
68
|
+
epoch: "lucarne-bridge",
|
|
69
|
+
instanceId: "lucarne-bridge",
|
|
70
|
+
provider: "lucarne",
|
|
71
|
+
revision: ++revision,
|
|
72
|
+
observedAt: new Date().toISOString(),
|
|
73
|
+
displayBounds: { left: 0, top: 0, right: 1920, bottom: 1080, width: 1920, height: 1080 },
|
|
74
|
+
windows,
|
|
75
|
+
lifecycle: { pid: process.pid, panes: [] },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
// reverse-proxy /sessions/:id/view* → lucarne (inject the lucarne token here)
|
|
79
|
+
function proxyView(req, res, rest) {
|
|
80
|
+
const target = new URL(LUCARNE_URL);
|
|
81
|
+
const qs = LUCARNE_TOKEN ? `?token=${encodeURIComponent(LUCARNE_TOKEN)}` : "";
|
|
82
|
+
const preq = http.request({
|
|
83
|
+
hostname: target.hostname,
|
|
84
|
+
port: target.port,
|
|
85
|
+
method: req.method,
|
|
86
|
+
path: `/sessions/${rest}${qs}`,
|
|
87
|
+
headers: { ...req.headers, host: target.host },
|
|
88
|
+
}, (pres) => {
|
|
89
|
+
res.writeHead(pres.statusCode ?? 502, pres.headers);
|
|
90
|
+
pres.pipe(res);
|
|
91
|
+
});
|
|
92
|
+
preq.on("error", () => { try {
|
|
93
|
+
res.writeHead(502);
|
|
94
|
+
res.end("bridge: lucarne unreachable");
|
|
95
|
+
}
|
|
96
|
+
catch { /* */ } });
|
|
97
|
+
req.pipe(preq);
|
|
98
|
+
}
|
|
99
|
+
const server = http.createServer(async (req, res) => {
|
|
100
|
+
try {
|
|
101
|
+
const pathname = new URL(req.url ?? "/", "http://x").pathname;
|
|
102
|
+
if (pathname === "/healthz") {
|
|
103
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
104
|
+
res.end(JSON.stringify({ ok: true, provider: "lucarne", instanceId: "lucarne-bridge", build: { version: "0.1.0" } }));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (pathname === "/api/mirror/snapshot") {
|
|
108
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
109
|
+
res.end(JSON.stringify(await snapshot()));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const view = pathname.match(/^\/sessions\/(.*)$/);
|
|
113
|
+
if (view) {
|
|
114
|
+
proxyView(req, res, view[1]);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
res.writeHead(404);
|
|
118
|
+
res.end();
|
|
119
|
+
}
|
|
120
|
+
catch (e) {
|
|
121
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
122
|
+
res.end(JSON.stringify({ error: String(e.message ?? e) }));
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
async function registerWithConsole() {
|
|
126
|
+
for (let i = 0; i < 5; i++) {
|
|
127
|
+
try {
|
|
128
|
+
const r = await fetch(`${CONSOLE_URL}/api/registry/local-providers`, {
|
|
129
|
+
method: "POST",
|
|
130
|
+
headers: { "content-type": "application/json" },
|
|
131
|
+
body: JSON.stringify({ baseUrl: PUBLIC_URL, label: "lucarne" }),
|
|
132
|
+
});
|
|
133
|
+
if (r.ok) {
|
|
134
|
+
process.stdout.write(`registered with console ${CONSOLE_URL}\n`);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
process.stderr.write(`register attempt ${i + 1}: HTTP ${r.status} ${await r.text()}\n`);
|
|
138
|
+
}
|
|
139
|
+
catch (e) {
|
|
140
|
+
process.stderr.write(`register attempt ${i + 1}: ${e.message}\n`);
|
|
141
|
+
}
|
|
142
|
+
await new Promise((res) => setTimeout(res, 1000));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
server.listen(PORT, HOST, () => {
|
|
146
|
+
process.stdout.write(`@termfleet/lucarne bridge on ${PUBLIC_URL} → lucarne ${LUCARNE_URL}\n`);
|
|
147
|
+
void registerWithConsole();
|
|
148
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@termfleet/lucarne",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Optional bridge that surfaces a lucarne browser-session engine inside a termfleet console as iframe-porthole windows. termfleet and lucarne stay independent; this is the only module that knows both.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Aaron Volter",
|
|
8
|
+
"engines": { "node": ">=22" },
|
|
9
|
+
"bin": { "termfleet-lucarne": "dist/index.js" },
|
|
10
|
+
"main": "dist/index.js",
|
|
11
|
+
"types": "dist/index.d.ts",
|
|
12
|
+
"files": ["dist", "README.md"],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"prepublishOnly": "npm run build"
|
|
16
|
+
},
|
|
17
|
+
"peerDependencies": {
|
|
18
|
+
"lucarne": ">=0.3.0"
|
|
19
|
+
},
|
|
20
|
+
"peerDependenciesMeta": {
|
|
21
|
+
"lucarne": { "optional": true }
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^22.10.0",
|
|
25
|
+
"typescript": "^5.7.0"
|
|
26
|
+
}
|
|
27
|
+
}
|