pubblue 0.3.0 → 0.4.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,40 @@
1
+ // src/lib/bridge-protocol.ts
2
+ var CONTROL_CHANNEL = "_control";
3
+ var CHANNELS = {
4
+ CHAT: "chat",
5
+ CANVAS: "canvas",
6
+ AUDIO: "audio",
7
+ MEDIA: "media",
8
+ FILE: "file"
9
+ };
10
+ var idCounter = 0;
11
+ function generateMessageId() {
12
+ const ts = Date.now().toString(36);
13
+ const seq = (idCounter++).toString(36);
14
+ const rand = Math.random().toString(36).slice(2, 6);
15
+ return `${ts}-${seq}-${rand}`;
16
+ }
17
+ function encodeMessage(msg) {
18
+ return JSON.stringify(msg);
19
+ }
20
+ function decodeMessage(raw) {
21
+ try {
22
+ const parsed = JSON.parse(raw);
23
+ if (parsed && typeof parsed.id === "string" && typeof parsed.type === "string") {
24
+ return parsed;
25
+ }
26
+ return null;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+ var MAX_TUNNEL_EXPIRY_MS = 7 * 24 * 60 * 60 * 1e3;
32
+ var DEFAULT_TUNNEL_EXPIRY_MS = 24 * 60 * 60 * 1e3;
33
+
34
+ export {
35
+ CONTROL_CHANNEL,
36
+ CHANNELS,
37
+ generateMessageId,
38
+ encodeMessage,
39
+ decodeMessage
40
+ };
@@ -0,0 +1,57 @@
1
+ // src/lib/tunnel-api.ts
2
+ var TunnelApiClient = class {
3
+ constructor(baseUrl, apiKey) {
4
+ this.baseUrl = baseUrl;
5
+ this.apiKey = apiKey;
6
+ }
7
+ async request(path, options = {}) {
8
+ const url = new URL(path, this.baseUrl);
9
+ const res = await fetch(url, {
10
+ ...options,
11
+ headers: {
12
+ "Content-Type": "application/json",
13
+ Authorization: `Bearer ${this.apiKey}`,
14
+ ...options.headers
15
+ }
16
+ });
17
+ let data;
18
+ try {
19
+ data = await res.json();
20
+ } catch {
21
+ data = {};
22
+ }
23
+ if (!res.ok) throw new Error(data.error || `Request failed: ${res.status}`);
24
+ return data;
25
+ }
26
+ async create(opts) {
27
+ return this.request("/api/v1/tunnels", {
28
+ method: "POST",
29
+ body: JSON.stringify(opts)
30
+ });
31
+ }
32
+ async get(tunnelId) {
33
+ const data = await this.request(
34
+ `/api/v1/tunnels/${encodeURIComponent(tunnelId)}`
35
+ );
36
+ return data.tunnel;
37
+ }
38
+ async list() {
39
+ const data = await this.request("/api/v1/tunnels/");
40
+ return data.tunnels;
41
+ }
42
+ async signal(tunnelId, opts) {
43
+ await this.request(`/api/v1/tunnels/${encodeURIComponent(tunnelId)}/signal`, {
44
+ method: "PATCH",
45
+ body: JSON.stringify(opts)
46
+ });
47
+ }
48
+ async close(tunnelId) {
49
+ await this.request(`/api/v1/tunnels/${encodeURIComponent(tunnelId)}`, {
50
+ method: "DELETE"
51
+ });
52
+ }
53
+ };
54
+
55
+ export {
56
+ TunnelApiClient
57
+ };
@@ -0,0 +1,311 @@
1
+ import {
2
+ CHANNELS,
3
+ CONTROL_CHANNEL,
4
+ decodeMessage,
5
+ encodeMessage
6
+ } from "./chunk-56IKFMJ2.js";
7
+
8
+ // src/lib/tunnel-daemon.ts
9
+ import * as fs from "fs";
10
+ import * as net from "net";
11
+ import * as path from "path";
12
+ async function startDaemon(config) {
13
+ const { tunnelId, apiClient, socketPath, infoPath } = config;
14
+ const ndc = await import("node-datachannel");
15
+ const buffer = { messages: [] };
16
+ const startTime = Date.now();
17
+ let connected = false;
18
+ let pollingInterval = null;
19
+ let lastBrowserCandidateCount = 0;
20
+ let remoteDescriptionApplied = false;
21
+ const pendingRemoteCandidates = [];
22
+ const peer = new ndc.PeerConnection("agent", {
23
+ iceServers: ["stun:stun.l.google.com:19302", "stun:stun1.l.google.com:19302"]
24
+ });
25
+ const channels = /* @__PURE__ */ new Map();
26
+ const pendingInboundBinaryMeta = /* @__PURE__ */ new Map();
27
+ function openDataChannel(name) {
28
+ const existing = channels.get(name);
29
+ if (existing) return existing;
30
+ const dc = peer.createDataChannel(name, { ordered: true });
31
+ setupChannel(name, dc);
32
+ return dc;
33
+ }
34
+ async function waitForChannelOpen(dc, timeoutMs = 5e3) {
35
+ if (dc.isOpen()) return;
36
+ await new Promise((resolve, reject) => {
37
+ let settled = false;
38
+ const timeout = setTimeout(() => {
39
+ if (settled) return;
40
+ settled = true;
41
+ reject(new Error("DataChannel open timed out"));
42
+ }, timeoutMs);
43
+ dc.onOpen(() => {
44
+ if (settled) return;
45
+ settled = true;
46
+ clearTimeout(timeout);
47
+ resolve();
48
+ });
49
+ });
50
+ }
51
+ function setupChannel(name, dc) {
52
+ channels.set(name, dc);
53
+ dc.onMessage((data) => {
54
+ if (typeof data === "string") {
55
+ const msg = decodeMessage(data);
56
+ if (msg) {
57
+ if (msg.type === "binary" && !msg.data) {
58
+ pendingInboundBinaryMeta.set(name, msg);
59
+ return;
60
+ }
61
+ buffer.messages.push({ channel: name, msg, timestamp: Date.now() });
62
+ }
63
+ } else {
64
+ const pendingMeta = pendingInboundBinaryMeta.get(name);
65
+ if (pendingMeta) pendingInboundBinaryMeta.delete(name);
66
+ const binMsg = pendingMeta ? {
67
+ id: pendingMeta.id,
68
+ type: "binary",
69
+ data: data.toString("base64"),
70
+ meta: { ...pendingMeta.meta, size: data.length }
71
+ } : {
72
+ id: `bin-${Date.now()}`,
73
+ type: "binary",
74
+ data: data.toString("base64"),
75
+ meta: { size: data.length }
76
+ };
77
+ buffer.messages.push({ channel: name, msg: binMsg, timestamp: Date.now() });
78
+ }
79
+ });
80
+ }
81
+ openDataChannel(CONTROL_CHANNEL);
82
+ openDataChannel(CHANNELS.CHAT);
83
+ openDataChannel(CHANNELS.CANVAS);
84
+ const localCandidates = [];
85
+ peer.onLocalCandidate((candidate, mid) => {
86
+ localCandidates.push(JSON.stringify({ candidate, sdpMid: mid }));
87
+ });
88
+ peer.onStateChange((state) => {
89
+ if (state === "connected") {
90
+ connected = true;
91
+ if (pollingInterval) {
92
+ clearInterval(pollingInterval);
93
+ pollingInterval = null;
94
+ }
95
+ } else if (state === "disconnected" || state === "failed") {
96
+ connected = false;
97
+ }
98
+ });
99
+ peer.onDataChannel((dc) => {
100
+ setupChannel(dc.getLabel(), dc);
101
+ });
102
+ const offer = await new Promise((resolve) => {
103
+ peer.onLocalDescription((sdp, type) => {
104
+ resolve(JSON.stringify({ sdp, type }));
105
+ });
106
+ peer.setLocalDescription();
107
+ });
108
+ await apiClient.signal(tunnelId, { offer });
109
+ setTimeout(async () => {
110
+ if (localCandidates.length > 0) {
111
+ await apiClient.signal(tunnelId, { candidates: localCandidates }).catch(() => {
112
+ });
113
+ }
114
+ }, 1e3);
115
+ let lastSentCandidateCount = 0;
116
+ const candidateInterval = setInterval(async () => {
117
+ if (localCandidates.length > lastSentCandidateCount) {
118
+ const newOnes = localCandidates.slice(lastSentCandidateCount);
119
+ lastSentCandidateCount = localCandidates.length;
120
+ await apiClient.signal(tunnelId, { candidates: newOnes }).catch(() => {
121
+ });
122
+ }
123
+ }, 500);
124
+ setTimeout(() => clearInterval(candidateInterval), 3e4);
125
+ pollingInterval = setInterval(async () => {
126
+ try {
127
+ const tunnel = await apiClient.get(tunnelId);
128
+ if (tunnel.browserAnswer && !remoteDescriptionApplied) {
129
+ try {
130
+ const answer = JSON.parse(tunnel.browserAnswer);
131
+ peer.setRemoteDescription(answer.sdp, answer.type);
132
+ remoteDescriptionApplied = true;
133
+ while (pendingRemoteCandidates.length > 0) {
134
+ const next = pendingRemoteCandidates.shift();
135
+ if (!next) break;
136
+ try {
137
+ peer.addRemoteCandidate(next.candidate, next.sdpMid);
138
+ } catch {
139
+ }
140
+ }
141
+ } catch {
142
+ }
143
+ }
144
+ if (tunnel.browserCandidates.length > lastBrowserCandidateCount) {
145
+ const newCandidates = tunnel.browserCandidates.slice(lastBrowserCandidateCount);
146
+ lastBrowserCandidateCount = tunnel.browserCandidates.length;
147
+ for (const c of newCandidates) {
148
+ try {
149
+ const parsed = JSON.parse(c);
150
+ if (typeof parsed.candidate !== "string") continue;
151
+ const sdpMid = typeof parsed.sdpMid === "string" ? parsed.sdpMid : "0";
152
+ if (!remoteDescriptionApplied) {
153
+ pendingRemoteCandidates.push({ candidate: parsed.candidate, sdpMid });
154
+ continue;
155
+ }
156
+ peer.addRemoteCandidate(parsed.candidate, sdpMid);
157
+ } catch {
158
+ }
159
+ }
160
+ }
161
+ } catch {
162
+ }
163
+ }, 500);
164
+ if (fs.existsSync(socketPath)) {
165
+ const infoFile = infoPath;
166
+ let stale = true;
167
+ try {
168
+ const raw = fs.readFileSync(infoFile, "utf-8");
169
+ const info = JSON.parse(raw);
170
+ process.kill(info.pid, 0);
171
+ stale = false;
172
+ } catch {
173
+ stale = true;
174
+ }
175
+ if (stale) {
176
+ try {
177
+ fs.unlinkSync(socketPath);
178
+ } catch {
179
+ }
180
+ } else {
181
+ throw new Error(`Daemon already running (socket: ${socketPath})`);
182
+ }
183
+ }
184
+ const ipcServer = net.createServer((conn) => {
185
+ let data = "";
186
+ conn.on("data", (chunk) => {
187
+ data += chunk.toString();
188
+ const newlineIdx = data.indexOf("\n");
189
+ if (newlineIdx === -1) return;
190
+ const line = data.slice(0, newlineIdx);
191
+ data = data.slice(newlineIdx + 1);
192
+ let request;
193
+ try {
194
+ request = JSON.parse(line);
195
+ } catch {
196
+ conn.write(`${JSON.stringify({ ok: false, error: "Invalid JSON" })}
197
+ `);
198
+ return;
199
+ }
200
+ handleIpcRequest(request).then((response) => conn.write(`${JSON.stringify(response)}
201
+ `)).catch((err) => conn.write(`${JSON.stringify({ ok: false, error: String(err) })}
202
+ `));
203
+ });
204
+ });
205
+ ipcServer.listen(socketPath);
206
+ async function handleIpcRequest(req) {
207
+ switch (req.method) {
208
+ case "write": {
209
+ const channel = req.params.channel || CHANNELS.CHAT;
210
+ const msg = req.params.msg;
211
+ const binaryBase64 = typeof req.params.binaryBase64 === "string" ? req.params.binaryBase64 : void 0;
212
+ const dc = channels.get(channel);
213
+ let targetDc = dc;
214
+ if (!targetDc) {
215
+ const newDc = openDataChannel(channel);
216
+ targetDc = newDc;
217
+ }
218
+ try {
219
+ await waitForChannelOpen(targetDc);
220
+ } catch (error) {
221
+ const message = error instanceof Error ? error.message : String(error);
222
+ return { ok: false, error: `Channel "${channel}" not open: ${message}` };
223
+ }
224
+ if (msg.type === "binary" && binaryBase64) {
225
+ const payload = Buffer.from(binaryBase64, "base64");
226
+ targetDc.sendMessage(
227
+ encodeMessage({
228
+ ...msg,
229
+ meta: {
230
+ ...msg.meta || {},
231
+ size: payload.length
232
+ }
233
+ })
234
+ );
235
+ targetDc.sendMessageBinary(payload);
236
+ } else {
237
+ targetDc.sendMessage(encodeMessage(msg));
238
+ }
239
+ return { ok: true };
240
+ }
241
+ case "read": {
242
+ const channel = req.params.channel;
243
+ let msgs;
244
+ if (channel) {
245
+ msgs = buffer.messages.filter((m) => m.channel === channel);
246
+ buffer.messages = buffer.messages.filter((m) => m.channel !== channel);
247
+ } else {
248
+ msgs = [...buffer.messages];
249
+ buffer.messages = [];
250
+ }
251
+ return { ok: true, messages: msgs };
252
+ }
253
+ case "channels": {
254
+ const chList = [...channels.keys()].map((name) => ({
255
+ name,
256
+ direction: "bidi"
257
+ }));
258
+ return { ok: true, channels: chList };
259
+ }
260
+ case "status": {
261
+ return {
262
+ ok: true,
263
+ connected,
264
+ uptime: Math.floor((Date.now() - startTime) / 1e3),
265
+ channels: [...channels.keys()],
266
+ bufferedMessages: buffer.messages.length
267
+ };
268
+ }
269
+ case "close": {
270
+ void shutdown();
271
+ return { ok: true };
272
+ }
273
+ default:
274
+ return { ok: false, error: `Unknown method: ${req.method}` };
275
+ }
276
+ }
277
+ async function shutdown() {
278
+ if (pollingInterval) clearInterval(pollingInterval);
279
+ for (const dc of channels.values()) dc.close();
280
+ peer.close();
281
+ ipcServer.close();
282
+ try {
283
+ fs.unlinkSync(socketPath);
284
+ } catch {
285
+ }
286
+ try {
287
+ fs.unlinkSync(infoPath);
288
+ } catch {
289
+ }
290
+ await apiClient.close(tunnelId).catch(() => {
291
+ });
292
+ process.exit(0);
293
+ }
294
+ process.on("SIGTERM", () => void shutdown());
295
+ process.on("SIGINT", () => void shutdown());
296
+ const infoDir = path.dirname(infoPath);
297
+ if (!fs.existsSync(infoDir)) fs.mkdirSync(infoDir, { recursive: true });
298
+ fs.writeFileSync(
299
+ infoPath,
300
+ JSON.stringify({
301
+ pid: process.pid,
302
+ tunnelId,
303
+ socketPath,
304
+ startedAt: startTime
305
+ })
306
+ );
307
+ }
308
+
309
+ export {
310
+ startDaemon
311
+ };
package/dist/index.js CHANGED
@@ -1,19 +1,439 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ TunnelApiClient
4
+ } from "./chunk-BV423NLA.js";
5
+ import {
6
+ generateMessageId
7
+ } from "./chunk-56IKFMJ2.js";
2
8
 
3
9
  // src/index.ts
4
- import * as fs2 from "fs";
5
- import * as path2 from "path";
10
+ import * as fs3 from "fs";
11
+ import * as path3 from "path";
6
12
  import { createInterface } from "readline/promises";
7
13
  import { Command } from "commander";
8
14
 
15
+ // src/commands/tunnel.ts
16
+ import { fork } from "child_process";
17
+ import * as fs2 from "fs";
18
+ import * as path2 from "path";
19
+
20
+ // src/lib/config.ts
21
+ import * as fs from "fs";
22
+ import * as os from "os";
23
+ import * as path from "path";
24
+ var DEFAULT_BASE_URL = "https://silent-guanaco-514.convex.site";
25
+ function getConfigDir(homeDir) {
26
+ const home = homeDir || os.homedir();
27
+ return path.join(home, ".config", "pubblue");
28
+ }
29
+ function getConfigPath(homeDir) {
30
+ const dir = getConfigDir(homeDir);
31
+ fs.mkdirSync(dir, { recursive: true, mode: 448 });
32
+ try {
33
+ fs.chmodSync(dir, 448);
34
+ } catch {
35
+ }
36
+ return path.join(dir, "config.json");
37
+ }
38
+ function loadConfig(homeDir) {
39
+ const configPath = getConfigPath(homeDir);
40
+ if (!fs.existsSync(configPath)) return null;
41
+ const raw = fs.readFileSync(configPath, "utf-8");
42
+ return JSON.parse(raw);
43
+ }
44
+ function saveConfig(config, homeDir) {
45
+ const configPath = getConfigPath(homeDir);
46
+ fs.writeFileSync(configPath, `${JSON.stringify({ apiKey: config.apiKey }, null, 2)}
47
+ `, {
48
+ mode: 384
49
+ });
50
+ try {
51
+ fs.chmodSync(configPath, 384);
52
+ } catch {
53
+ }
54
+ }
55
+ function getConfig(homeDir) {
56
+ const envKey = process.env.PUBBLUE_API_KEY;
57
+ const envUrl = process.env.PUBBLUE_URL;
58
+ const baseUrl = envUrl || DEFAULT_BASE_URL;
59
+ if (envKey) {
60
+ return { apiKey: envKey, baseUrl };
61
+ }
62
+ const saved = loadConfig(homeDir);
63
+ if (!saved) {
64
+ throw new Error(
65
+ "Not configured. Run `pubblue configure` or set PUBBLUE_API_KEY environment variable."
66
+ );
67
+ }
68
+ return {
69
+ apiKey: saved.apiKey,
70
+ baseUrl
71
+ };
72
+ }
73
+
74
+ // src/lib/tunnel-ipc.ts
75
+ import * as net from "net";
76
+ function getSocketPath(tunnelId) {
77
+ return `/tmp/pubblue-${tunnelId}.sock`;
78
+ }
79
+ async function ipcCall(socketPath, request) {
80
+ return new Promise((resolve3, reject) => {
81
+ let settled = false;
82
+ let timeoutId = null;
83
+ const finish = (fn) => {
84
+ if (settled) return;
85
+ settled = true;
86
+ if (timeoutId) clearTimeout(timeoutId);
87
+ fn();
88
+ };
89
+ const client = net.createConnection(socketPath, () => {
90
+ client.write(`${JSON.stringify(request)}
91
+ `);
92
+ });
93
+ let data = "";
94
+ client.on("data", (chunk) => {
95
+ data += chunk.toString();
96
+ const newlineIdx = data.indexOf("\n");
97
+ if (newlineIdx !== -1) {
98
+ const line = data.slice(0, newlineIdx);
99
+ client.end();
100
+ try {
101
+ finish(() => resolve3(JSON.parse(line)));
102
+ } catch {
103
+ finish(() => reject(new Error("Invalid response from daemon")));
104
+ }
105
+ }
106
+ });
107
+ client.on("error", (err) => {
108
+ if (err.code === "ECONNREFUSED" || err.code === "ENOENT") {
109
+ finish(() => reject(new Error("Daemon not running. Is the tunnel still active?")));
110
+ } else {
111
+ finish(() => reject(err));
112
+ }
113
+ });
114
+ client.on("end", () => {
115
+ if (!data.includes("\n")) {
116
+ finish(() => reject(new Error("Daemon closed connection unexpectedly")));
117
+ }
118
+ });
119
+ timeoutId = setTimeout(() => {
120
+ client.destroy();
121
+ finish(() => reject(new Error("Daemon request timed out")));
122
+ }, 1e4);
123
+ });
124
+ }
125
+
126
+ // src/commands/tunnel.ts
127
+ var TEXT_FILE_EXTENSIONS = /* @__PURE__ */ new Set([
128
+ ".txt",
129
+ ".md",
130
+ ".markdown",
131
+ ".json",
132
+ ".csv",
133
+ ".xml",
134
+ ".yaml",
135
+ ".yml",
136
+ ".js",
137
+ ".mjs",
138
+ ".cjs",
139
+ ".ts",
140
+ ".tsx",
141
+ ".jsx",
142
+ ".css",
143
+ ".scss",
144
+ ".sass",
145
+ ".less",
146
+ ".log"
147
+ ]);
148
+ function getMimeType(filePath) {
149
+ const ext = path2.extname(filePath).toLowerCase();
150
+ const mimeByExt = {
151
+ ".html": "text/html; charset=utf-8",
152
+ ".htm": "text/html; charset=utf-8",
153
+ ".txt": "text/plain; charset=utf-8",
154
+ ".md": "text/markdown; charset=utf-8",
155
+ ".markdown": "text/markdown; charset=utf-8",
156
+ ".json": "application/json",
157
+ ".csv": "text/csv; charset=utf-8",
158
+ ".xml": "application/xml",
159
+ ".yaml": "application/x-yaml",
160
+ ".yml": "application/x-yaml",
161
+ ".png": "image/png",
162
+ ".jpg": "image/jpeg",
163
+ ".jpeg": "image/jpeg",
164
+ ".gif": "image/gif",
165
+ ".webp": "image/webp",
166
+ ".svg": "image/svg+xml",
167
+ ".pdf": "application/pdf",
168
+ ".zip": "application/zip",
169
+ ".mp3": "audio/mpeg",
170
+ ".wav": "audio/wav",
171
+ ".mp4": "video/mp4"
172
+ };
173
+ return mimeByExt[ext] || "application/octet-stream";
174
+ }
175
+ function tunnelInfoDir() {
176
+ const dir = path2.join(
177
+ process.env.HOME || process.env.USERPROFILE || "/tmp",
178
+ ".config",
179
+ "pubblue",
180
+ "tunnels"
181
+ );
182
+ if (!fs2.existsSync(dir)) fs2.mkdirSync(dir, { recursive: true });
183
+ return dir;
184
+ }
185
+ function tunnelInfoPath(tunnelId) {
186
+ return path2.join(tunnelInfoDir(), `${tunnelId}.json`);
187
+ }
188
+ function createApiClient() {
189
+ const config = getConfig();
190
+ return new TunnelApiClient(config.baseUrl, config.apiKey);
191
+ }
192
+ async function ensureNodeDatachannelAvailable() {
193
+ try {
194
+ await import("node-datachannel");
195
+ } catch (error) {
196
+ const message = error instanceof Error ? error.message : String(error);
197
+ console.error("node-datachannel native module is not available.");
198
+ console.error("Run `pnpm rebuild node-datachannel` in the cli package and retry.");
199
+ console.error(`Details: ${message}`);
200
+ process.exit(1);
201
+ }
202
+ }
203
+ function isDaemonRunning(tunnelId) {
204
+ const infoPath = tunnelInfoPath(tunnelId);
205
+ if (!fs2.existsSync(infoPath)) return false;
206
+ try {
207
+ const info = JSON.parse(fs2.readFileSync(infoPath, "utf-8"));
208
+ process.kill(info.pid, 0);
209
+ return true;
210
+ } catch {
211
+ try {
212
+ fs2.unlinkSync(infoPath);
213
+ } catch {
214
+ }
215
+ return false;
216
+ }
217
+ }
218
+ function registerTunnelCommands(program2) {
219
+ const tunnel = program2.command("tunnel").description("P2P encrypted tunnel to browser");
220
+ tunnel.command("start").description("Start a new tunnel (spawns background daemon)").option("--title <title>", "Tunnel title").option("--expires <duration>", "Auto-close after duration (e.g. 4h, 1d)", "24h").option("--foreground", "Run in foreground (don't fork)").action(async (opts) => {
221
+ await ensureNodeDatachannelAvailable();
222
+ const apiClient = createApiClient();
223
+ const result = await apiClient.create({
224
+ title: opts.title,
225
+ expiresIn: opts.expires
226
+ });
227
+ const socketPath = getSocketPath(result.tunnelId);
228
+ const infoPath = tunnelInfoPath(result.tunnelId);
229
+ if (opts.foreground) {
230
+ const { startDaemon } = await import("./tunnel-daemon-CHNV373I.js");
231
+ console.log(`Tunnel started: ${result.url}`);
232
+ console.log(`Tunnel ID: ${result.tunnelId}`);
233
+ console.log(`Expires: ${new Date(result.expiresAt).toISOString()}`);
234
+ console.log("Running in foreground. Press Ctrl+C to stop.");
235
+ await startDaemon({
236
+ tunnelId: result.tunnelId,
237
+ apiClient,
238
+ socketPath,
239
+ infoPath
240
+ });
241
+ } else {
242
+ const daemonScript = path2.join(import.meta.dirname, "..", "tunnel-daemon-entry.js");
243
+ const config = getConfig();
244
+ const child = fork(daemonScript, [], {
245
+ detached: true,
246
+ stdio: "ignore",
247
+ env: {
248
+ ...process.env,
249
+ PUBBLUE_DAEMON_TUNNEL_ID: result.tunnelId,
250
+ PUBBLUE_DAEMON_BASE_URL: config.baseUrl,
251
+ PUBBLUE_DAEMON_API_KEY: config.apiKey,
252
+ PUBBLUE_DAEMON_SOCKET: socketPath,
253
+ PUBBLUE_DAEMON_INFO: infoPath
254
+ }
255
+ });
256
+ child.unref();
257
+ console.log(`Tunnel started: ${result.url}`);
258
+ console.log(`Tunnel ID: ${result.tunnelId}`);
259
+ console.log(`Expires: ${new Date(result.expiresAt).toISOString()}`);
260
+ }
261
+ });
262
+ tunnel.command("write").description("Write data to a channel").argument("[message]", "Text message (or use --file)").option("-t, --tunnel <tunnelId>", "Tunnel ID (auto-detected if one active)").option("-c, --channel <channel>", "Channel name", "chat").option("-f, --file <file>", "Read content from file").action(
263
+ async (messageArg, opts) => {
264
+ let msg;
265
+ let binaryBase64;
266
+ if (opts.file) {
267
+ const filePath = path2.resolve(opts.file);
268
+ const ext = path2.extname(filePath).toLowerCase();
269
+ const bytes = fs2.readFileSync(filePath);
270
+ const filename = path2.basename(filePath);
271
+ if (ext === ".html" || ext === ".htm") {
272
+ msg = {
273
+ id: generateMessageId(),
274
+ type: "html",
275
+ data: bytes.toString("utf-8"),
276
+ meta: { title: filename, filename, mime: getMimeType(filePath), size: bytes.length }
277
+ };
278
+ } else if (TEXT_FILE_EXTENSIONS.has(ext)) {
279
+ msg = {
280
+ id: generateMessageId(),
281
+ type: "text",
282
+ data: bytes.toString("utf-8"),
283
+ meta: { filename, mime: getMimeType(filePath), size: bytes.length }
284
+ };
285
+ } else {
286
+ msg = {
287
+ id: generateMessageId(),
288
+ type: "binary",
289
+ meta: { filename, mime: getMimeType(filePath), size: bytes.length }
290
+ };
291
+ binaryBase64 = bytes.toString("base64");
292
+ }
293
+ } else if (messageArg) {
294
+ msg = {
295
+ id: generateMessageId(),
296
+ type: "text",
297
+ data: messageArg
298
+ };
299
+ } else {
300
+ const chunks = [];
301
+ for await (const chunk of process.stdin) chunks.push(chunk);
302
+ msg = {
303
+ id: generateMessageId(),
304
+ type: "text",
305
+ data: Buffer.concat(chunks).toString("utf-8").trim()
306
+ };
307
+ }
308
+ const tunnelId = opts.tunnel || await resolveActiveTunnel();
309
+ const socketPath = getSocketPath(tunnelId);
310
+ const response = await ipcCall(socketPath, {
311
+ method: "write",
312
+ params: { channel: opts.channel, msg, binaryBase64 }
313
+ });
314
+ if (!response.ok) {
315
+ console.error(`Failed: ${response.error}`);
316
+ process.exit(1);
317
+ }
318
+ }
319
+ );
320
+ tunnel.command("read").description("Read buffered messages from channels").argument("[tunnelId]", "Tunnel ID (auto-detected if one active)").option("-c, --channel <channel>", "Filter by channel").option("--follow", "Stream messages continuously").action(
321
+ async (tunnelIdArg, opts) => {
322
+ const tunnelId = tunnelIdArg || await resolveActiveTunnel();
323
+ const socketPath = getSocketPath(tunnelId);
324
+ if (opts.follow) {
325
+ while (true) {
326
+ const response = await ipcCall(socketPath, {
327
+ method: "read",
328
+ params: { channel: opts.channel }
329
+ }).catch(() => null);
330
+ if (!response) {
331
+ console.error("Daemon disconnected.");
332
+ process.exit(1);
333
+ }
334
+ if (response.messages && response.messages.length > 0) {
335
+ for (const m of response.messages) {
336
+ console.log(JSON.stringify(m));
337
+ }
338
+ }
339
+ await new Promise((r) => setTimeout(r, 1e3));
340
+ }
341
+ } else {
342
+ const response = await ipcCall(socketPath, {
343
+ method: "read",
344
+ params: { channel: opts.channel }
345
+ });
346
+ if (!response.ok) {
347
+ console.error(`Failed: ${response.error}`);
348
+ process.exit(1);
349
+ }
350
+ console.log(JSON.stringify(response.messages || [], null, 2));
351
+ }
352
+ }
353
+ );
354
+ tunnel.command("channels").description("List active channels").argument("[tunnelId]", "Tunnel ID").action(async (tunnelIdArg) => {
355
+ const tunnelId = tunnelIdArg || await resolveActiveTunnel();
356
+ const socketPath = getSocketPath(tunnelId);
357
+ const response = await ipcCall(socketPath, { method: "channels", params: {} });
358
+ if (response.channels) {
359
+ for (const ch of response.channels) {
360
+ console.log(` ${ch.name} [${ch.direction}]`);
361
+ }
362
+ }
363
+ });
364
+ tunnel.command("status").description("Check tunnel connection status").argument("[tunnelId]", "Tunnel ID").action(async (tunnelIdArg) => {
365
+ const tunnelId = tunnelIdArg || await resolveActiveTunnel();
366
+ const socketPath = getSocketPath(tunnelId);
367
+ const response = await ipcCall(socketPath, { method: "status", params: {} });
368
+ console.log(` Status: ${response.connected ? "connected" : "waiting"}`);
369
+ console.log(` Uptime: ${response.uptime}s`);
370
+ const chNames = Array.isArray(response.channels) ? response.channels.map((c) => typeof c === "string" ? c : String(c)) : [];
371
+ console.log(` Channels: ${chNames.join(", ")}`);
372
+ console.log(` Buffered: ${response.bufferedMessages ?? 0} messages`);
373
+ });
374
+ tunnel.command("list").description("List active tunnels").action(async () => {
375
+ const apiClient = createApiClient();
376
+ const tunnels = await apiClient.list();
377
+ if (tunnels.length === 0) {
378
+ console.log("No active tunnels.");
379
+ return;
380
+ }
381
+ for (const t of tunnels) {
382
+ const age = Math.floor((Date.now() - t.createdAt) / 6e4);
383
+ const running = isDaemonRunning(t.tunnelId) ? "running" : "no daemon";
384
+ const conn = t.hasConnection ? "connected" : "waiting";
385
+ console.log(
386
+ ` ${t.tunnelId} ${t.title || "(untitled)"} ${conn} ${running} ${age}m ago`
387
+ );
388
+ }
389
+ });
390
+ tunnel.command("close").description("Close a tunnel and stop its daemon").argument("<tunnelId>", "Tunnel ID").action(async (tunnelId) => {
391
+ const socketPath = getSocketPath(tunnelId);
392
+ let closedByDaemon = false;
393
+ try {
394
+ const daemonResult = await ipcCall(socketPath, { method: "close", params: {} });
395
+ closedByDaemon = daemonResult.ok;
396
+ } catch {
397
+ closedByDaemon = false;
398
+ }
399
+ if (!closedByDaemon) {
400
+ const apiClient = createApiClient();
401
+ try {
402
+ await apiClient.close(tunnelId);
403
+ } catch (error) {
404
+ const message = error instanceof Error ? error.message : String(error);
405
+ console.error(`Failed to close tunnel ${tunnelId}: ${message}`);
406
+ process.exit(1);
407
+ }
408
+ }
409
+ console.log(`Closed: ${tunnelId}`);
410
+ });
411
+ }
412
+ async function resolveActiveTunnel() {
413
+ const dir = tunnelInfoDir();
414
+ const files = fs2.readdirSync(dir).filter((f) => f.endsWith(".json"));
415
+ const active = [];
416
+ for (const f of files) {
417
+ const tunnelId = f.replace(".json", "");
418
+ if (isDaemonRunning(tunnelId)) active.push(tunnelId);
419
+ }
420
+ if (active.length === 0) {
421
+ console.error("No active tunnels. Run `pubblue tunnel start` first.");
422
+ process.exit(1);
423
+ }
424
+ if (active.length === 1) return active[0];
425
+ console.error(`Multiple active tunnels: ${active.join(", ")}. Specify one.`);
426
+ process.exit(1);
427
+ }
428
+
9
429
  // src/lib/api.ts
10
430
  var PubApiClient = class {
11
431
  constructor(baseUrl, apiKey) {
12
432
  this.baseUrl = baseUrl;
13
433
  this.apiKey = apiKey;
14
434
  }
15
- async request(path3, options = {}) {
16
- const url = new URL(path3, this.baseUrl);
435
+ async request(path4, options = {}) {
436
+ const url = new URL(path4, this.baseUrl);
17
437
  const res = await fetch(url, {
18
438
  ...options,
19
439
  headers: {
@@ -71,60 +491,6 @@ var PubApiClient = class {
71
491
  }
72
492
  };
73
493
 
74
- // src/lib/config.ts
75
- import * as fs from "fs";
76
- import * as os from "os";
77
- import * as path from "path";
78
- var DEFAULT_BASE_URL = "https://silent-guanaco-514.convex.site";
79
- function getConfigDir(homeDir) {
80
- const home = homeDir || os.homedir();
81
- return path.join(home, ".config", "pubblue");
82
- }
83
- function getConfigPath(homeDir) {
84
- const dir = getConfigDir(homeDir);
85
- fs.mkdirSync(dir, { recursive: true, mode: 448 });
86
- try {
87
- fs.chmodSync(dir, 448);
88
- } catch {
89
- }
90
- return path.join(dir, "config.json");
91
- }
92
- function loadConfig(homeDir) {
93
- const configPath = getConfigPath(homeDir);
94
- if (!fs.existsSync(configPath)) return null;
95
- const raw = fs.readFileSync(configPath, "utf-8");
96
- return JSON.parse(raw);
97
- }
98
- function saveConfig(config, homeDir) {
99
- const configPath = getConfigPath(homeDir);
100
- fs.writeFileSync(configPath, `${JSON.stringify({ apiKey: config.apiKey }, null, 2)}
101
- `, {
102
- mode: 384
103
- });
104
- try {
105
- fs.chmodSync(configPath, 384);
106
- } catch {
107
- }
108
- }
109
- function getConfig(homeDir) {
110
- const envKey = process.env.PUBBLUE_API_KEY;
111
- const envUrl = process.env.PUBBLUE_URL;
112
- const baseUrl = envUrl || DEFAULT_BASE_URL;
113
- if (envKey) {
114
- return { apiKey: envKey, baseUrl };
115
- }
116
- const saved = loadConfig(homeDir);
117
- if (!saved) {
118
- throw new Error(
119
- "Not configured. Run `pubblue configure` or set PUBBLUE_API_KEY environment variable."
120
- );
121
- }
122
- return {
123
- apiKey: saved.apiKey,
124
- baseUrl
125
- };
126
- }
127
-
128
494
  // src/index.ts
129
495
  var program = new Command();
130
496
  function createClient() {
@@ -173,17 +539,17 @@ async function resolveConfigureApiKey(opts) {
173
539
  return readApiKeyFromPrompt();
174
540
  }
175
541
  function readFile(filePath) {
176
- const resolved = path2.resolve(filePath);
177
- if (!fs2.existsSync(resolved)) {
542
+ const resolved = path3.resolve(filePath);
543
+ if (!fs3.existsSync(resolved)) {
178
544
  console.error(`File not found: ${resolved}`);
179
545
  process.exit(1);
180
546
  }
181
547
  return {
182
- content: fs2.readFileSync(resolved, "utf-8"),
183
- basename: path2.basename(resolved)
548
+ content: fs3.readFileSync(resolved, "utf-8"),
549
+ basename: path3.basename(resolved)
184
550
  };
185
551
  }
186
- program.name("pubblue").description("Publish static content and get shareable URLs").version("0.3.0");
552
+ program.name("pubblue").description("Publish static content and get shareable URLs").version("0.4.0");
187
553
  program.command("configure").description("Configure the CLI with your API key").option("--api-key <key>", "Your API key (less secure: appears in shell history)").option("--api-key-stdin", "Read API key from stdin").action(async (opts) => {
188
554
  try {
189
555
  const apiKey = await resolveConfigureApiKey(opts);
@@ -195,7 +561,7 @@ program.command("configure").description("Configure the CLI with your API key").
195
561
  process.exit(1);
196
562
  }
197
563
  });
198
- program.command("create").description("Create a new publication").argument("[file]", "Path to the file (reads stdin if omitted)").option("--slug <slug>", "Custom slug for the URL").option("--title <title>", "Title for the publication").option("--public", "Make the publication public (default: private)").option("--private", "Make the publication private (this is the default)").option("--expires <duration>", "Auto-delete after duration (e.g. 1h, 24h, 7d)").action(
564
+ program.command("create").description("Create a new publication").argument("[file]", "Path to the file (reads stdin if omitted)").option("--slug <slug>", "Custom slug for the URL").option("--title <title>", "Title for the publication").option("--private", "Make the publication private (default)").option("--expires <duration>", "Auto-delete after duration (e.g. 1h, 24h, 7d)").action(
199
565
  async (fileArg, opts) => {
200
566
  const client = createClient();
201
567
  let content;
@@ -212,7 +578,7 @@ program.command("create").description("Create a new publication").argument("[fil
212
578
  filename,
213
579
  title: opts.title,
214
580
  slug: opts.slug,
215
- isPublic: opts.public ?? false,
581
+ isPublic: false,
216
582
  expiresIn: opts.expires
217
583
  });
218
584
  console.log(`Created: ${result.url}`);
@@ -237,7 +603,7 @@ program.command("get").description("Get details of a publication").argument("<sl
237
603
  console.log(` Updated: ${new Date(pub.updatedAt).toLocaleDateString()}`);
238
604
  console.log(` Size: ${pub.content.length} bytes`);
239
605
  });
240
- program.command("update").description("Update a publication's content and/or metadata").argument("<slug>", "Slug of the publication to update").option("--file <file>", "New content from file").option("--title <title>", "New title").option("--public", "Make the publication public").option("--private", "Make the publication private").option("--slug <newSlug>", "Rename the slug").action(
606
+ program.command("update").description("Update a publication's content and/or metadata").argument("<slug>", "Slug of the publication to update").option("--file <file>", "New content from file").option("--title <title>", "New title").option("--private", "Make the publication private").option("--slug <newSlug>", "Rename the slug").action(
241
607
  async (slug, opts) => {
242
608
  const client = createClient();
243
609
  let content;
@@ -248,8 +614,7 @@ program.command("update").description("Update a publication's content and/or met
248
614
  filename = file.basename;
249
615
  }
250
616
  let isPublic;
251
- if (opts.public) isPublic = true;
252
- else if (opts.private) isPublic = false;
617
+ if (opts.private) isPublic = false;
253
618
  const result = await client.update({
254
619
  slug,
255
620
  content,
@@ -283,4 +648,5 @@ program.command("delete").description("Delete a publication").argument("<slug>",
283
648
  await client.remove(slug);
284
649
  console.log(`Deleted: ${slug}`);
285
650
  });
651
+ registerTunnelCommands(program);
286
652
  program.parse();
@@ -0,0 +1,7 @@
1
+ import {
2
+ startDaemon
3
+ } from "./chunk-OLY5PC4A.js";
4
+ import "./chunk-56IKFMJ2.js";
5
+ export {
6
+ startDaemon
7
+ };
@@ -0,0 +1,2 @@
1
+
2
+ export { }
@@ -0,0 +1,24 @@
1
+ import {
2
+ TunnelApiClient
3
+ } from "./chunk-BV423NLA.js";
4
+ import {
5
+ startDaemon
6
+ } from "./chunk-OLY5PC4A.js";
7
+ import "./chunk-56IKFMJ2.js";
8
+
9
+ // src/tunnel-daemon-entry.ts
10
+ var tunnelId = process.env.PUBBLUE_DAEMON_TUNNEL_ID;
11
+ var baseUrl = process.env.PUBBLUE_DAEMON_BASE_URL;
12
+ var apiKey = process.env.PUBBLUE_DAEMON_API_KEY;
13
+ var socketPath = process.env.PUBBLUE_DAEMON_SOCKET;
14
+ var infoPath = process.env.PUBBLUE_DAEMON_INFO;
15
+ if (!tunnelId || !baseUrl || !apiKey || !socketPath || !infoPath) {
16
+ console.error("Missing required env vars for daemon.");
17
+ process.exit(1);
18
+ }
19
+ var apiClient = new TunnelApiClient(baseUrl, apiKey);
20
+ void startDaemon({ tunnelId, apiClient, socketPath, infoPath }).catch((error) => {
21
+ const message = error instanceof Error ? error.message : String(error);
22
+ console.error(`Tunnel daemon failed to start: ${message}`);
23
+ process.exit(1);
24
+ });
package/package.json CHANGED
@@ -1,20 +1,21 @@
1
1
  {
2
2
  "name": "pubblue",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "CLI tool for publishing static content via pub.blue",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "pubblue": "./dist/index.js"
8
8
  },
9
9
  "scripts": {
10
- "build": "tsup src/index.ts --format esm --dts --clean",
11
- "dev": "tsup src/index.ts --format esm --watch",
10
+ "build": "tsup src/index.ts src/tunnel-daemon-entry.ts --format esm --dts --clean",
11
+ "dev": "tsup src/index.ts src/tunnel-daemon-entry.ts --format esm --watch",
12
12
  "test": "vitest run",
13
13
  "test:watch": "vitest",
14
14
  "lint": "tsc --noEmit"
15
15
  },
16
16
  "dependencies": {
17
- "commander": "^13.0.0"
17
+ "commander": "^13.0.0",
18
+ "node-datachannel": "^0.32.0"
18
19
  },
19
20
  "devDependencies": {
20
21
  "@types/node": "22.10.2",
@@ -35,7 +36,8 @@
35
36
  },
36
37
  "pnpm": {
37
38
  "onlyBuiltDependencies": [
38
- "esbuild"
39
+ "esbuild",
40
+ "node-datachannel"
39
41
  ]
40
42
  }
41
43
  }