pubblue 0.3.0 → 0.4.1

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,342 @@
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
+ var OFFER_TIMEOUT_MS = 1e4;
13
+ async function startDaemon(config) {
14
+ const { tunnelId, apiClient, socketPath, infoPath } = config;
15
+ const ndc = await import("node-datachannel");
16
+ const buffer = { messages: [] };
17
+ const startTime = Date.now();
18
+ let connected = false;
19
+ let pollingInterval = null;
20
+ let lastBrowserCandidateCount = 0;
21
+ let remoteDescriptionApplied = false;
22
+ const pendingRemoteCandidates = [];
23
+ const peer = new ndc.PeerConnection("agent", {
24
+ iceServers: ["stun:stun.l.google.com:19302", "stun:stun1.l.google.com:19302"]
25
+ });
26
+ const channels = /* @__PURE__ */ new Map();
27
+ const pendingInboundBinaryMeta = /* @__PURE__ */ new Map();
28
+ function openDataChannel(name) {
29
+ const existing = channels.get(name);
30
+ if (existing) return existing;
31
+ const dc = peer.createDataChannel(name, { ordered: true });
32
+ setupChannel(name, dc);
33
+ return dc;
34
+ }
35
+ async function waitForChannelOpen(dc, timeoutMs = 5e3) {
36
+ if (dc.isOpen()) return;
37
+ await new Promise((resolve, reject) => {
38
+ let settled = false;
39
+ const timeout = setTimeout(() => {
40
+ if (settled) return;
41
+ settled = true;
42
+ reject(new Error("DataChannel open timed out"));
43
+ }, timeoutMs);
44
+ dc.onOpen(() => {
45
+ if (settled) return;
46
+ settled = true;
47
+ clearTimeout(timeout);
48
+ resolve();
49
+ });
50
+ });
51
+ }
52
+ function setupChannel(name, dc) {
53
+ channels.set(name, dc);
54
+ dc.onMessage((data) => {
55
+ if (typeof data === "string") {
56
+ const msg = decodeMessage(data);
57
+ if (msg) {
58
+ if (msg.type === "binary" && !msg.data) {
59
+ pendingInboundBinaryMeta.set(name, msg);
60
+ return;
61
+ }
62
+ buffer.messages.push({ channel: name, msg, timestamp: Date.now() });
63
+ }
64
+ } else {
65
+ const pendingMeta = pendingInboundBinaryMeta.get(name);
66
+ if (pendingMeta) pendingInboundBinaryMeta.delete(name);
67
+ const binMsg = pendingMeta ? {
68
+ id: pendingMeta.id,
69
+ type: "binary",
70
+ data: data.toString("base64"),
71
+ meta: { ...pendingMeta.meta, size: data.length }
72
+ } : {
73
+ id: `bin-${Date.now()}`,
74
+ type: "binary",
75
+ data: data.toString("base64"),
76
+ meta: { size: data.length }
77
+ };
78
+ buffer.messages.push({ channel: name, msg: binMsg, timestamp: Date.now() });
79
+ }
80
+ });
81
+ }
82
+ openDataChannel(CONTROL_CHANNEL);
83
+ openDataChannel(CHANNELS.CHAT);
84
+ openDataChannel(CHANNELS.CANVAS);
85
+ const localCandidates = [];
86
+ peer.onLocalCandidate((candidate, mid) => {
87
+ localCandidates.push(JSON.stringify({ candidate, sdpMid: mid }));
88
+ });
89
+ peer.onStateChange((state) => {
90
+ if (state === "connected") {
91
+ connected = true;
92
+ if (pollingInterval) {
93
+ clearInterval(pollingInterval);
94
+ pollingInterval = null;
95
+ }
96
+ } else if (state === "disconnected" || state === "failed") {
97
+ connected = false;
98
+ }
99
+ });
100
+ peer.onDataChannel((dc) => {
101
+ setupChannel(dc.getLabel(), dc);
102
+ });
103
+ if (fs.existsSync(socketPath)) {
104
+ let stale = true;
105
+ try {
106
+ const raw = fs.readFileSync(infoPath, "utf-8");
107
+ const info = JSON.parse(raw);
108
+ process.kill(info.pid, 0);
109
+ stale = false;
110
+ } catch {
111
+ stale = true;
112
+ }
113
+ if (stale) {
114
+ try {
115
+ fs.unlinkSync(socketPath);
116
+ } catch {
117
+ }
118
+ } else {
119
+ throw new Error(`Daemon already running (socket: ${socketPath})`);
120
+ }
121
+ }
122
+ const ipcServer = net.createServer((conn) => {
123
+ let data = "";
124
+ conn.on("data", (chunk) => {
125
+ data += chunk.toString();
126
+ const newlineIdx = data.indexOf("\n");
127
+ if (newlineIdx === -1) return;
128
+ const line = data.slice(0, newlineIdx);
129
+ data = data.slice(newlineIdx + 1);
130
+ let request;
131
+ try {
132
+ request = JSON.parse(line);
133
+ } catch {
134
+ conn.write(`${JSON.stringify({ ok: false, error: "Invalid JSON" })}
135
+ `);
136
+ return;
137
+ }
138
+ handleIpcRequest(request).then((response) => conn.write(`${JSON.stringify(response)}
139
+ `)).catch((err) => conn.write(`${JSON.stringify({ ok: false, error: String(err) })}
140
+ `));
141
+ });
142
+ });
143
+ ipcServer.listen(socketPath);
144
+ const infoDir = path.dirname(infoPath);
145
+ if (!fs.existsSync(infoDir)) fs.mkdirSync(infoDir, { recursive: true });
146
+ fs.writeFileSync(
147
+ infoPath,
148
+ JSON.stringify({ pid: process.pid, tunnelId, socketPath, startedAt: startTime })
149
+ );
150
+ async function cleanup() {
151
+ if (pollingInterval) clearInterval(pollingInterval);
152
+ for (const dc of channels.values()) dc.close();
153
+ peer.close();
154
+ ipcServer.close();
155
+ try {
156
+ fs.unlinkSync(socketPath);
157
+ } catch {
158
+ }
159
+ try {
160
+ fs.unlinkSync(infoPath);
161
+ } catch {
162
+ }
163
+ await apiClient.close(tunnelId).catch(() => {
164
+ });
165
+ }
166
+ async function shutdown() {
167
+ await cleanup();
168
+ process.exit(0);
169
+ }
170
+ process.on("SIGTERM", () => void shutdown());
171
+ process.on("SIGINT", () => void shutdown());
172
+ let offer;
173
+ try {
174
+ offer = await generateOffer(peer, OFFER_TIMEOUT_MS);
175
+ } catch (error) {
176
+ const message = error instanceof Error ? error.message : String(error);
177
+ await cleanup();
178
+ throw new Error(`Failed to generate WebRTC offer: ${message}`);
179
+ }
180
+ await apiClient.signal(tunnelId, { offer });
181
+ setTimeout(async () => {
182
+ if (localCandidates.length > 0) {
183
+ await apiClient.signal(tunnelId, { candidates: localCandidates }).catch(() => {
184
+ });
185
+ }
186
+ }, 1e3);
187
+ let lastSentCandidateCount = 0;
188
+ const candidateInterval = setInterval(async () => {
189
+ if (localCandidates.length > lastSentCandidateCount) {
190
+ const newOnes = localCandidates.slice(lastSentCandidateCount);
191
+ lastSentCandidateCount = localCandidates.length;
192
+ await apiClient.signal(tunnelId, { candidates: newOnes }).catch(() => {
193
+ });
194
+ }
195
+ }, 500);
196
+ setTimeout(() => clearInterval(candidateInterval), 3e4);
197
+ pollingInterval = setInterval(async () => {
198
+ try {
199
+ const tunnel = await apiClient.get(tunnelId);
200
+ if (tunnel.browserAnswer && !remoteDescriptionApplied) {
201
+ try {
202
+ const answer = JSON.parse(tunnel.browserAnswer);
203
+ peer.setRemoteDescription(answer.sdp, answer.type);
204
+ remoteDescriptionApplied = true;
205
+ while (pendingRemoteCandidates.length > 0) {
206
+ const next = pendingRemoteCandidates.shift();
207
+ if (!next) break;
208
+ try {
209
+ peer.addRemoteCandidate(next.candidate, next.sdpMid);
210
+ } catch {
211
+ }
212
+ }
213
+ } catch {
214
+ }
215
+ }
216
+ if (tunnel.browserCandidates.length > lastBrowserCandidateCount) {
217
+ const newCandidates = tunnel.browserCandidates.slice(lastBrowserCandidateCount);
218
+ lastBrowserCandidateCount = tunnel.browserCandidates.length;
219
+ for (const c of newCandidates) {
220
+ try {
221
+ const parsed = JSON.parse(c);
222
+ if (typeof parsed.candidate !== "string") continue;
223
+ const sdpMid = typeof parsed.sdpMid === "string" ? parsed.sdpMid : "0";
224
+ if (!remoteDescriptionApplied) {
225
+ pendingRemoteCandidates.push({ candidate: parsed.candidate, sdpMid });
226
+ continue;
227
+ }
228
+ peer.addRemoteCandidate(parsed.candidate, sdpMid);
229
+ } catch {
230
+ }
231
+ }
232
+ }
233
+ } catch {
234
+ }
235
+ }, 500);
236
+ async function handleIpcRequest(req) {
237
+ switch (req.method) {
238
+ case "write": {
239
+ const channel = req.params.channel || CHANNELS.CHAT;
240
+ const msg = req.params.msg;
241
+ const binaryBase64 = typeof req.params.binaryBase64 === "string" ? req.params.binaryBase64 : void 0;
242
+ const dc = channels.get(channel);
243
+ let targetDc = dc;
244
+ if (!targetDc) {
245
+ const newDc = openDataChannel(channel);
246
+ targetDc = newDc;
247
+ }
248
+ try {
249
+ await waitForChannelOpen(targetDc);
250
+ } catch (error) {
251
+ const message = error instanceof Error ? error.message : String(error);
252
+ return { ok: false, error: `Channel "${channel}" not open: ${message}` };
253
+ }
254
+ if (msg.type === "binary" && binaryBase64) {
255
+ const payload = Buffer.from(binaryBase64, "base64");
256
+ targetDc.sendMessage(
257
+ encodeMessage({
258
+ ...msg,
259
+ meta: {
260
+ ...msg.meta || {},
261
+ size: payload.length
262
+ }
263
+ })
264
+ );
265
+ targetDc.sendMessageBinary(payload);
266
+ } else {
267
+ targetDc.sendMessage(encodeMessage(msg));
268
+ }
269
+ return { ok: true };
270
+ }
271
+ case "read": {
272
+ const channel = req.params.channel;
273
+ let msgs;
274
+ if (channel) {
275
+ msgs = buffer.messages.filter((m) => m.channel === channel);
276
+ buffer.messages = buffer.messages.filter((m) => m.channel !== channel);
277
+ } else {
278
+ msgs = [...buffer.messages];
279
+ buffer.messages = [];
280
+ }
281
+ return { ok: true, messages: msgs };
282
+ }
283
+ case "channels": {
284
+ const chList = [...channels.keys()].map((name) => ({
285
+ name,
286
+ direction: "bidi"
287
+ }));
288
+ return { ok: true, channels: chList };
289
+ }
290
+ case "status": {
291
+ return {
292
+ ok: true,
293
+ connected,
294
+ uptime: Math.floor((Date.now() - startTime) / 1e3),
295
+ channels: [...channels.keys()],
296
+ bufferedMessages: buffer.messages.length
297
+ };
298
+ }
299
+ case "close": {
300
+ void shutdown();
301
+ return { ok: true };
302
+ }
303
+ default:
304
+ return { ok: false, error: `Unknown method: ${req.method}` };
305
+ }
306
+ }
307
+ }
308
+ function generateOffer(peer, timeoutMs) {
309
+ return new Promise((resolve, reject) => {
310
+ let resolved = false;
311
+ const done = (sdp, type) => {
312
+ if (resolved) return;
313
+ resolved = true;
314
+ clearTimeout(timeout);
315
+ resolve(JSON.stringify({ sdp, type }));
316
+ };
317
+ peer.onLocalDescription((sdp, type) => {
318
+ done(sdp, type);
319
+ });
320
+ peer.onGatheringStateChange((state) => {
321
+ if (state === "complete" && !resolved) {
322
+ const desc = peer.localDescription();
323
+ if (desc) done(desc.sdp, desc.type);
324
+ }
325
+ });
326
+ const timeout = setTimeout(() => {
327
+ if (resolved) return;
328
+ const desc = peer.localDescription();
329
+ if (desc) {
330
+ done(desc.sdp, desc.type);
331
+ } else {
332
+ resolved = true;
333
+ reject(new Error(`Timed out after ${timeoutMs}ms`));
334
+ }
335
+ }, timeoutMs);
336
+ peer.setLocalDescription();
337
+ });
338
+ }
339
+
340
+ export {
341
+ startDaemon
342
+ };
@@ -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
+ };
package/dist/index.js CHANGED
@@ -1,19 +1,469 @@
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-QBJSX4JM.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
+ try {
236
+ await startDaemon({
237
+ tunnelId: result.tunnelId,
238
+ apiClient,
239
+ socketPath,
240
+ infoPath
241
+ });
242
+ } catch (error) {
243
+ const message = error instanceof Error ? error.message : String(error);
244
+ console.error(`Daemon failed: ${message}`);
245
+ process.exit(1);
246
+ }
247
+ } else {
248
+ const daemonScript = path2.join(import.meta.dirname, "tunnel-daemon-entry.js");
249
+ const config = getConfig();
250
+ const child = fork(daemonScript, [], {
251
+ detached: true,
252
+ stdio: "ignore",
253
+ env: {
254
+ ...process.env,
255
+ PUBBLUE_DAEMON_TUNNEL_ID: result.tunnelId,
256
+ PUBBLUE_DAEMON_BASE_URL: config.baseUrl,
257
+ PUBBLUE_DAEMON_API_KEY: config.apiKey,
258
+ PUBBLUE_DAEMON_SOCKET: socketPath,
259
+ PUBBLUE_DAEMON_INFO: infoPath
260
+ }
261
+ });
262
+ child.unref();
263
+ const ready = await waitForDaemonReady(infoPath, child, 5e3);
264
+ if (!ready) {
265
+ console.error("Daemon failed to start. Cleaning up tunnel...");
266
+ await apiClient.close(result.tunnelId).catch(() => {
267
+ });
268
+ process.exit(1);
269
+ }
270
+ console.log(`Tunnel started: ${result.url}`);
271
+ console.log(`Tunnel ID: ${result.tunnelId}`);
272
+ console.log(`Expires: ${new Date(result.expiresAt).toISOString()}`);
273
+ }
274
+ });
275
+ 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(
276
+ async (messageArg, opts) => {
277
+ let msg;
278
+ let binaryBase64;
279
+ if (opts.file) {
280
+ const filePath = path2.resolve(opts.file);
281
+ const ext = path2.extname(filePath).toLowerCase();
282
+ const bytes = fs2.readFileSync(filePath);
283
+ const filename = path2.basename(filePath);
284
+ if (ext === ".html" || ext === ".htm") {
285
+ msg = {
286
+ id: generateMessageId(),
287
+ type: "html",
288
+ data: bytes.toString("utf-8"),
289
+ meta: { title: filename, filename, mime: getMimeType(filePath), size: bytes.length }
290
+ };
291
+ } else if (TEXT_FILE_EXTENSIONS.has(ext)) {
292
+ msg = {
293
+ id: generateMessageId(),
294
+ type: "text",
295
+ data: bytes.toString("utf-8"),
296
+ meta: { filename, mime: getMimeType(filePath), size: bytes.length }
297
+ };
298
+ } else {
299
+ msg = {
300
+ id: generateMessageId(),
301
+ type: "binary",
302
+ meta: { filename, mime: getMimeType(filePath), size: bytes.length }
303
+ };
304
+ binaryBase64 = bytes.toString("base64");
305
+ }
306
+ } else if (messageArg) {
307
+ msg = {
308
+ id: generateMessageId(),
309
+ type: "text",
310
+ data: messageArg
311
+ };
312
+ } else {
313
+ const chunks = [];
314
+ for await (const chunk of process.stdin) chunks.push(chunk);
315
+ msg = {
316
+ id: generateMessageId(),
317
+ type: "text",
318
+ data: Buffer.concat(chunks).toString("utf-8").trim()
319
+ };
320
+ }
321
+ const tunnelId = opts.tunnel || await resolveActiveTunnel();
322
+ const socketPath = getSocketPath(tunnelId);
323
+ const response = await ipcCall(socketPath, {
324
+ method: "write",
325
+ params: { channel: opts.channel, msg, binaryBase64 }
326
+ });
327
+ if (!response.ok) {
328
+ console.error(`Failed: ${response.error}`);
329
+ process.exit(1);
330
+ }
331
+ }
332
+ );
333
+ 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(
334
+ async (tunnelIdArg, opts) => {
335
+ const tunnelId = tunnelIdArg || await resolveActiveTunnel();
336
+ const socketPath = getSocketPath(tunnelId);
337
+ if (opts.follow) {
338
+ while (true) {
339
+ const response = await ipcCall(socketPath, {
340
+ method: "read",
341
+ params: { channel: opts.channel }
342
+ }).catch(() => null);
343
+ if (!response) {
344
+ console.error("Daemon disconnected.");
345
+ process.exit(1);
346
+ }
347
+ if (response.messages && response.messages.length > 0) {
348
+ for (const m of response.messages) {
349
+ console.log(JSON.stringify(m));
350
+ }
351
+ }
352
+ await new Promise((r) => setTimeout(r, 1e3));
353
+ }
354
+ } else {
355
+ const response = await ipcCall(socketPath, {
356
+ method: "read",
357
+ params: { channel: opts.channel }
358
+ });
359
+ if (!response.ok) {
360
+ console.error(`Failed: ${response.error}`);
361
+ process.exit(1);
362
+ }
363
+ console.log(JSON.stringify(response.messages || [], null, 2));
364
+ }
365
+ }
366
+ );
367
+ tunnel.command("channels").description("List active channels").argument("[tunnelId]", "Tunnel ID").action(async (tunnelIdArg) => {
368
+ const tunnelId = tunnelIdArg || await resolveActiveTunnel();
369
+ const socketPath = getSocketPath(tunnelId);
370
+ const response = await ipcCall(socketPath, { method: "channels", params: {} });
371
+ if (response.channels) {
372
+ for (const ch of response.channels) {
373
+ console.log(` ${ch.name} [${ch.direction}]`);
374
+ }
375
+ }
376
+ });
377
+ tunnel.command("status").description("Check tunnel connection status").argument("[tunnelId]", "Tunnel ID").action(async (tunnelIdArg) => {
378
+ const tunnelId = tunnelIdArg || await resolveActiveTunnel();
379
+ const socketPath = getSocketPath(tunnelId);
380
+ const response = await ipcCall(socketPath, { method: "status", params: {} });
381
+ console.log(` Status: ${response.connected ? "connected" : "waiting"}`);
382
+ console.log(` Uptime: ${response.uptime}s`);
383
+ const chNames = Array.isArray(response.channels) ? response.channels.map((c) => typeof c === "string" ? c : String(c)) : [];
384
+ console.log(` Channels: ${chNames.join(", ")}`);
385
+ console.log(` Buffered: ${response.bufferedMessages ?? 0} messages`);
386
+ });
387
+ tunnel.command("list").description("List active tunnels").action(async () => {
388
+ const apiClient = createApiClient();
389
+ const tunnels = await apiClient.list();
390
+ if (tunnels.length === 0) {
391
+ console.log("No active tunnels.");
392
+ return;
393
+ }
394
+ for (const t of tunnels) {
395
+ const age = Math.floor((Date.now() - t.createdAt) / 6e4);
396
+ const running = isDaemonRunning(t.tunnelId) ? "running" : "no daemon";
397
+ const conn = t.hasConnection ? "connected" : "waiting";
398
+ console.log(
399
+ ` ${t.tunnelId} ${t.title || "(untitled)"} ${conn} ${running} ${age}m ago`
400
+ );
401
+ }
402
+ });
403
+ tunnel.command("close").description("Close a tunnel and stop its daemon").argument("<tunnelId>", "Tunnel ID").action(async (tunnelId) => {
404
+ const socketPath = getSocketPath(tunnelId);
405
+ let closedByDaemon = false;
406
+ try {
407
+ const daemonResult = await ipcCall(socketPath, { method: "close", params: {} });
408
+ closedByDaemon = daemonResult.ok;
409
+ } catch {
410
+ closedByDaemon = false;
411
+ }
412
+ if (!closedByDaemon) {
413
+ const apiClient = createApiClient();
414
+ try {
415
+ await apiClient.close(tunnelId);
416
+ } catch (error) {
417
+ const message = error instanceof Error ? error.message : String(error);
418
+ console.error(`Failed to close tunnel ${tunnelId}: ${message}`);
419
+ process.exit(1);
420
+ }
421
+ }
422
+ console.log(`Closed: ${tunnelId}`);
423
+ });
424
+ }
425
+ async function resolveActiveTunnel() {
426
+ const dir = tunnelInfoDir();
427
+ const files = fs2.readdirSync(dir).filter((f) => f.endsWith(".json"));
428
+ const active = [];
429
+ for (const f of files) {
430
+ const tunnelId = f.replace(".json", "");
431
+ if (isDaemonRunning(tunnelId)) active.push(tunnelId);
432
+ }
433
+ if (active.length === 0) {
434
+ console.error("No active tunnels. Run `pubblue tunnel start` first.");
435
+ process.exit(1);
436
+ }
437
+ if (active.length === 1) return active[0];
438
+ console.error(`Multiple active tunnels: ${active.join(", ")}. Specify one.`);
439
+ process.exit(1);
440
+ }
441
+ function waitForDaemonReady(infoPath, child, timeoutMs) {
442
+ return new Promise((resolve3) => {
443
+ let settled = false;
444
+ const done = (value) => {
445
+ if (settled) return;
446
+ settled = true;
447
+ clearInterval(poll);
448
+ clearTimeout(timeout);
449
+ resolve3(value);
450
+ };
451
+ child.on("exit", () => done(false));
452
+ const poll = setInterval(() => {
453
+ if (fs2.existsSync(infoPath)) done(true);
454
+ }, 100);
455
+ const timeout = setTimeout(() => done(false), timeoutMs);
456
+ });
457
+ }
458
+
9
459
  // src/lib/api.ts
10
460
  var PubApiClient = class {
11
461
  constructor(baseUrl, apiKey) {
12
462
  this.baseUrl = baseUrl;
13
463
  this.apiKey = apiKey;
14
464
  }
15
- async request(path3, options = {}) {
16
- const url = new URL(path3, this.baseUrl);
465
+ async request(path4, options = {}) {
466
+ const url = new URL(path4, this.baseUrl);
17
467
  const res = await fetch(url, {
18
468
  ...options,
19
469
  headers: {
@@ -71,60 +521,6 @@ var PubApiClient = class {
71
521
  }
72
522
  };
73
523
 
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
524
  // src/index.ts
129
525
  var program = new Command();
130
526
  function createClient() {
@@ -173,17 +569,17 @@ async function resolveConfigureApiKey(opts) {
173
569
  return readApiKeyFromPrompt();
174
570
  }
175
571
  function readFile(filePath) {
176
- const resolved = path2.resolve(filePath);
177
- if (!fs2.existsSync(resolved)) {
572
+ const resolved = path3.resolve(filePath);
573
+ if (!fs3.existsSync(resolved)) {
178
574
  console.error(`File not found: ${resolved}`);
179
575
  process.exit(1);
180
576
  }
181
577
  return {
182
- content: fs2.readFileSync(resolved, "utf-8"),
183
- basename: path2.basename(resolved)
578
+ content: fs3.readFileSync(resolved, "utf-8"),
579
+ basename: path3.basename(resolved)
184
580
  };
185
581
  }
186
- program.name("pubblue").description("Publish static content and get shareable URLs").version("0.3.0");
582
+ program.name("pubblue").description("Publish static content and get shareable URLs").version("0.4.1");
187
583
  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
584
  try {
189
585
  const apiKey = await resolveConfigureApiKey(opts);
@@ -195,7 +591,7 @@ program.command("configure").description("Configure the CLI with your API key").
195
591
  process.exit(1);
196
592
  }
197
593
  });
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(
594
+ 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
595
  async (fileArg, opts) => {
200
596
  const client = createClient();
201
597
  let content;
@@ -212,7 +608,7 @@ program.command("create").description("Create a new publication").argument("[fil
212
608
  filename,
213
609
  title: opts.title,
214
610
  slug: opts.slug,
215
- isPublic: opts.public ?? false,
611
+ isPublic: false,
216
612
  expiresIn: opts.expires
217
613
  });
218
614
  console.log(`Created: ${result.url}`);
@@ -237,7 +633,7 @@ program.command("get").description("Get details of a publication").argument("<sl
237
633
  console.log(` Updated: ${new Date(pub.updatedAt).toLocaleDateString()}`);
238
634
  console.log(` Size: ${pub.content.length} bytes`);
239
635
  });
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(
636
+ 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
637
  async (slug, opts) => {
242
638
  const client = createClient();
243
639
  let content;
@@ -248,8 +644,7 @@ program.command("update").description("Update a publication's content and/or met
248
644
  filename = file.basename;
249
645
  }
250
646
  let isPublic;
251
- if (opts.public) isPublic = true;
252
- else if (opts.private) isPublic = false;
647
+ if (opts.private) isPublic = false;
253
648
  const result = await client.update({
254
649
  slug,
255
650
  content,
@@ -283,4 +678,5 @@ program.command("delete").description("Delete a publication").argument("<slug>",
283
678
  await client.remove(slug);
284
679
  console.log(`Deleted: ${slug}`);
285
680
  });
681
+ registerTunnelCommands(program);
286
682
  program.parse();
@@ -0,0 +1,7 @@
1
+ import {
2
+ startDaemon
3
+ } from "./chunk-77HFJKLW.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-77HFJKLW.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.1",
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
  }