pinggy 0.5.3 → 0.5.6
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/ARCHITECTURE.md +596 -0
- package/README.md +4 -0
- package/dist/{TunnelManager-OPUMAZFX.js → TunnelManager-ZSVX7U3Z.js} +2 -2
- package/dist/{TunnelTui-QZEWWH2H.js → TunnelTui-S35BNVPO.js} +85 -16
- package/dist/{chunk-GK2PQ7KX.js → chunk-HN4YLM7U.js} +227 -74
- package/dist/{chunk-BBPR2LJB.js → chunk-OH6FSLYF.js} +39 -9
- package/dist/{chunk-7G6SJEEA.js → chunk-P7WNBN7F.js} +1 -1
- package/dist/{chunk-DLNUDW6G.js → chunk-PQSLSIDA.js} +67 -13
- package/dist/{chunk-5YHD6M35.js → chunk-YTJEV27N.js} +2 -2
- package/dist/{chunk-WM7I57AA.js → chunk-YWXADADB.js} +1 -1
- package/dist/{configStore-HP3NXPH4.js → configStore-2AZRILVC.js} +2 -2
- package/dist/{daemonChild-WIRWKBKB.js → daemonChild-3ONQO2XI.js} +6 -6
- package/dist/index.cjs +420 -114
- package/dist/index.d.cts +24 -17
- package/dist/index.d.ts +24 -17
- package/dist/index.js +6 -6
- package/dist/{ipcClient-Z2RXDQUQ.js → ipcClient-XRL2WUUM.js} +1 -1
- package/dist/{main-B2EBHFN2.js → main-WXFARRMN.js} +6 -6
- package/dist/workers/file_serve_worker.js +1 -1
- package/jest.config.cjs +1 -0
- package/package.json +2 -2
package/ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
x# Pinggy CLI Architecture
|
|
2
|
+
|
|
3
|
+
Reference for developers and AI agents working on the `@pinggy/cli` codebase. Covers the two-process daemon model, IPC, state, session ownership, and module layout introduced on the `feat/daemon` branch.
|
|
4
|
+
|
|
5
|
+
For domain language (Tunnel, Daemon, Client, Origin, Foreground mode, etc.) see `CONTEXT.md`. For project rules and English style see `CLAUDE.md`. This file documents how the pieces fit together.
|
|
6
|
+
|
|
7
|
+
## 1. The two-process model
|
|
8
|
+
|
|
9
|
+
The `pinggy` binary has three execution modes, dispatched in `src/main.ts`:
|
|
10
|
+
|
|
11
|
+
| Mode | Trigger | Entry | Purpose |
|
|
12
|
+
| -------------------- | -------------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
|
13
|
+
| Daemon child | `--_daemon-child` flag | `runDaemonChild()` in `src/daemon/daemonChild.ts` | Long-running background process. Owns the `TunnelManager` and the SDK. |
|
|
14
|
+
| Subcommand | First arg in `SUBCOMMANDS` set | `handleSubcommand()` in `src/cli/subcommands.ts` | Short-lived CLI invocation:`config`, `start`, `stop`, `ps`, `attach`, `daemon`, `logs`, `log`, `restart`. |
|
|
15
|
+
| Legacy single-tunnel | No subcommand, has flags | `buildAndStartTunnel()` in `src/cli/buildAndStartTunnel.ts` | Backwards-compatible single-shot tunnel for flags like `-l 3000` or `-R0:...`. |
|
|
16
|
+
|
|
17
|
+
The CLI never opens an SDK tunnel itself. Every tunnel is created inside the daemon. CLI processes talk to the daemon over HTTP + WebSocket on `127.0.0.1`.
|
|
18
|
+
|
|
19
|
+

|
|
20
|
+
|
|
21
|
+
A given user has **one** daemon. Whichever client (CLI or App) finds no live daemon spawns one; later clients discover it via `daemon.json`.
|
|
22
|
+
|
|
23
|
+
## 2. Filesystem layout
|
|
24
|
+
|
|
25
|
+
State lives under `getPinggyConfigDir()` (`src/utils/configDir.ts`):
|
|
26
|
+
|
|
27
|
+
- Linux: `$XDG_CONFIG_HOME/pinggy` (default `~/.config/pinggy`)
|
|
28
|
+
- macOS: `~/.config/pinggy`
|
|
29
|
+
- Windows: `%APPDATA%/pinggy`
|
|
30
|
+
|
|
31
|
+
Logs live under `getPinggyLogDir()`:
|
|
32
|
+
|
|
33
|
+
- Linux: `$XDG_STATE_HOME/pinggy-cli/logs`
|
|
34
|
+
- macOS: `~/Library/Logs/Pinggy-CLI`
|
|
35
|
+
- Windows: `%LOCALAPPDATA%/Pinggy-CLI/Logs`
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
<configDir>/
|
|
39
|
+
├── daemon.json Daemon discovery: {pid, port, startedAt}
|
|
40
|
+
├── daemon-state.json Active tunnel snapshot for crash recovery
|
|
41
|
+
└── tunnels/
|
|
42
|
+
└── <name>_<configId>.json Saved tunnel configs (from `pinggy config save`)
|
|
43
|
+
|
|
44
|
+
<logDir>/
|
|
45
|
+
├── daemon.log Daemon process log (rolled)
|
|
46
|
+
└── tunnels/
|
|
47
|
+
└── <origin>__<name>__<tunnelId>.log Per-tunnel logs (rolled 10MB×3)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Helpers live in `src/utils/configDir.ts`: `getDaemonInfoPath`, `getDaemonLogPath`, `getTunnelLogPath`, `getTunnelConfigDir`.
|
|
51
|
+
|
|
52
|
+
## 3. Daemon discovery and spawn
|
|
53
|
+
|
|
54
|
+
Every CLI invocation that touches a tunnel asks one question first: is there a live daemon to talk to? `getDaemonInfo()` answers it by reading `daemon.json` and validating the recorded PID with `process.kill(pid, 0)`. Signal 0 never delivers anything; it just throws when the target process is gone, which is exactly the liveness probe we need. A missing file, malformed JSON, or a dead PID all collapse to the same result: return `null`, and let `ensureDaemonRunning()` spawn a fresh child.
|
|
55
|
+
|
|
56
|
+
The spawn path is deliberately one-way. The CLI fires off a detached child with the `--_daemon-child` flag and then polls `daemon.json` until the child writes it. The poll budget is 8 seconds at 200ms intervals; a timeout almost always means the SDK failed to load inside the child, in which case the stderr captured from the spawned process is surfaced in the thrown error. Atomic writes of `daemon.json` (write to `.tmp`, then `fs.renameSync`) keep concurrent readers from ever seeing a half-written file.
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
CLI starts
|
|
60
|
+
│
|
|
61
|
+
▼
|
|
62
|
+
getDaemonInfo() ─── reads daemon.json
|
|
63
|
+
│
|
|
64
|
+
├─ file missing ──▶ return null
|
|
65
|
+
├─ malformed ──▶ return null
|
|
66
|
+
├─ PID dead (kill -0 fails)──▶ unlink daemon.json, return null
|
|
67
|
+
└─ PID alive ──▶ return DaemonInfo
|
|
68
|
+
│
|
|
69
|
+
▼
|
|
70
|
+
has DaemonInfo? ─── yes ──▶ connect IPCClient(port)
|
|
71
|
+
│
|
|
72
|
+
no
|
|
73
|
+
▼
|
|
74
|
+
startDaemon()
|
|
75
|
+
│
|
|
76
|
+
├─ spawn detached child with --_daemon-child
|
|
77
|
+
│ Unix: spawn(execPath, [argv[1], "--_daemon-child"], {detached, stdio: pipe-stderr})
|
|
78
|
+
│ Windows: same + windowsHide:true
|
|
79
|
+
│ Electron: calls ensuredaemon() which internally starts the daemon server if no daemon is there
|
|
80
|
+
│
|
|
81
|
+
├─ child.unref()
|
|
82
|
+
│
|
|
83
|
+
└─ pollForDaemonInfo(8000ms, interval 200ms)
|
|
84
|
+
│
|
|
85
|
+
├─ daemon.json appears ──▶ return DaemonInfo
|
|
86
|
+
├─ child exits early ──▶ throw with stderr
|
|
87
|
+
└─ timeout ──▶ throw "Daemon failed to start within timeout"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Implementation: `src/daemon/daemonManager.ts`.
|
|
91
|
+
|
|
92
|
+
The race between two CLIs invoked at the same moment is bounded but not eliminated: both may observe no `daemon.json`, both may spawn a child. Whichever child's atomic write lands last wins the file; the other daemon is reachable via its port but unreachable through discovery, and dies when its IPC server is closed or its parent CLI exits. In practice this is rare enough that we accept it; tightening it would require a file lock and adds more complexity than it removes.
|
|
93
|
+
|
|
94
|
+
## 4. Daemon child startup sequence
|
|
95
|
+
|
|
96
|
+
`runDaemonChild()` runs once per daemon lifetime, inside the spawned child process. The step order matters: signal handlers must register before any work that could throw, the IPC port must be open before `daemon.json` is written, and crash recovery must run before auto-start so a restored tunnel never races a freshly-spawned auto-start one.
|
|
97
|
+
|
|
98
|
+
`runDaemonChild()` in `src/daemon/daemonChild.ts`:
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
1. ensurePinggyConfigDir(), ensurePinggyLogDir()
|
|
102
|
+
2. Configure winston logger → file: daemon.log, stdout: false
|
|
103
|
+
3. Create TunnelManager singleton
|
|
104
|
+
4. Create IPCServer (HTTP + WS, port not yet bound)
|
|
105
|
+
5. Create SessionTracker, wire onSessionDisconnect
|
|
106
|
+
6. Register signal handlers: SIGTERM, SIGINT, exit, uncaughtException
|
|
107
|
+
7. ipcServer.listen() → returns OS-assigned port
|
|
108
|
+
8. Write daemon.json atomically (write to .tmp, rename)
|
|
109
|
+
9. restoreCrashedTunnels() — replay detached tunnels from daemon-state.json
|
|
110
|
+
10. Start auto-start tunnels (getAutoStartConfigs())
|
|
111
|
+
11. Log "Daemon ready"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Step 8 is the contract the rest of the system depends on: once `daemon.json` exists with a live PID, the IPC server is already accepting connections. CLIs that read the file can immediately POST or open a WebSocket; there is no "warming up" window. Steps 9 and 10 may take time when the Pinggy edge is slow, so "Daemon ready" only means the daemon is reachable, not that every tunnel is live. Callers that need tunnel readiness must poll `/tunnels` or subscribe to `url_ready` over the WebSocket.
|
|
117
|
+
|
|
118
|
+
## 5. IPC: HTTP routes
|
|
119
|
+
|
|
120
|
+
HTTP carries every request/response operation: start, stop, list, restart, log management, shutdown. The transport is plain `http` on `127.0.0.1` with an OS-assigned port. Loopback isolation is the security boundary; there is no auth on these routes, so binding anywhere other than `127.0.0.1` is unsafe.
|
|
121
|
+
|
|
122
|
+
Server: `src/daemon/ipcServer.ts`. Client: `src/daemon/ipcClient.ts`. Public facade: `src/daemon/tunnelClient.ts`.
|
|
123
|
+
|
|
124
|
+
| Method | Path | Purpose |
|
|
125
|
+
| ------ | ----------------------------------------------------------- | ----------------------------------------------- |
|
|
126
|
+
| GET | `/ping` | Health + uptime |
|
|
127
|
+
| GET | `/tunnels` | List V2 |
|
|
128
|
+
| GET | `/tunnels/:id` | Get one |
|
|
129
|
+
| POST | `/tunnels/start` | Start by saved-config name |
|
|
130
|
+
| POST | `/tunnels/start-config` | Start with inline V2 config |
|
|
131
|
+
| POST | `/tunnels/stop` | `{tunnelid}` |
|
|
132
|
+
| POST | `/tunnels/restart` | `{tunnelid}` |
|
|
133
|
+
| GET | `/tunnels-v1` | List V1 (compat) |
|
|
134
|
+
| POST | `/tunnels/start-v1` | V1 start (used by remote management) |
|
|
135
|
+
| POST | `/tunnels/update-config` | V1 update |
|
|
136
|
+
| POST | `/tunnels/update-config-v2` | V2 update |
|
|
137
|
+
| POST | `/tunnels/remove-stopped` | Remove a stopped tunnel by tunnelid or configId |
|
|
138
|
+
| GET | `/loglevel`, POST `/loglevel` | Daemon-wide log level |
|
|
139
|
+
| GET | `/config/tunnel-logging`, POST `/config/tunnel-logging` | Tunnel logging toggle |
|
|
140
|
+
| GET | `/logs/paths` | All tunnel log file paths + running flags |
|
|
141
|
+
| GET | `/logs/resolve?q=` | Resolve a name/id/configId to a log path |
|
|
142
|
+
| POST | `/shutdown` | Graceful shutdown |
|
|
143
|
+
|
|
144
|
+
Conventions:
|
|
145
|
+
|
|
146
|
+
- Every request sets `X-Pinggy-Origin: app|cli|remote` (defaulted in `parseOrigin()` to `cli`). Stored on the tunnel and used in log filenames.
|
|
147
|
+
- V2 is the canonical shape. V1 routes (`/tunnels-v1`, `/tunnels/start-v1`, `/tunnels/update-config`) exist for remote management payloads that still use the older schema. New code should target V2.
|
|
148
|
+
- `start` is idempotent on `configId`. If a tunnel with the same `configId` is already running, the daemon returns `ErrorResponse{code: TunnelAlreadyRunningError}` and the CLI prints the existing state instead of starting a duplicate.
|
|
149
|
+
- A successful operation returns its typed response with HTTP 200. An expected application-level failure returns `ErrorResponse` JSON, still with HTTP 200; the route handled the request, the operation just failed. Non-200 means a transport-level problem (daemon died mid-request, port in use after spawn, etc.) and surfaces as a thrown error in `IPCClient`.
|
|
150
|
+
- The `mode` field (added to `start`, `start-config`, `start-v1`) tells `trackIPCTunnelStart` whether to persist the tunnel to `daemon-state.json`. Foreground tunnels are not persisted; detached tunnels are. The default is `"detached"`, which is what remote management and `DaemonTunnelHandler` rely on.
|
|
151
|
+
|
|
152
|
+
## 6. IPC: WebSocket event stream
|
|
153
|
+
|
|
154
|
+
Path: `ws://127.0.0.1:<port>/ws`. Schema in `src/daemon/wsProtocol.ts`.
|
|
155
|
+
|
|
156
|
+
Client → Daemon:
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
{ type: "subscribe", tunnelId: "...", mode: "foreground" | "detached" }
|
|
160
|
+
{ type: "unsubscribe", tunnelId: "..." }
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Daemon → Client (always wrapped in a `tunnel_event`):
|
|
164
|
+
|
|
165
|
+
```
|
|
166
|
+
{
|
|
167
|
+
type: "tunnel_event",
|
|
168
|
+
tunnelId: "...",
|
|
169
|
+
event: "url_ready" | "stats" | "disconnect" | "reconnecting" | "reconnected"
|
|
170
|
+
| "reconnection_failed" | "error" | "stopped" | "will_reconnect"
|
|
171
|
+
| "worker_error" | "subscribed" | "error_response",
|
|
172
|
+
payload: { ... }
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
On `subscribe`, the daemon registers a fan-out of listeners against `TunnelManager` for the tunnel and remembers their listener IDs on the session. On `unsubscribe` or session close, every listener is deregistered. See `handleSubscribe`, `deregisterListeners`, `cleanupSession` in `ipcServer.ts`.
|
|
177
|
+
|
|
178
|
+
```
|
|
179
|
+
Subscribe sequence:
|
|
180
|
+
|
|
181
|
+
CLI Daemon TunnelManager
|
|
182
|
+
│ ws.send(subscribe) │ │
|
|
183
|
+
├──────────────────────────────▶ │
|
|
184
|
+
│ │ registerStatsListener │
|
|
185
|
+
│ ├───────────────────────────────▶│
|
|
186
|
+
│ │ registerDisconnectListener │
|
|
187
|
+
│ ├───────────────────────────────▶│
|
|
188
|
+
│ │ (… 7 more listeners) │
|
|
189
|
+
│ │ │
|
|
190
|
+
│ │ commit subscription │
|
|
191
|
+
│ │ sessionTracker.attach(tid, sid, mode)
|
|
192
|
+
│ tunnel_event "subscribed" │ │
|
|
193
|
+
◀──────────────────────────────┤ │
|
|
194
|
+
│ │ stats fired ◀────────────────│
|
|
195
|
+
│ tunnel_event "stats" │ │
|
|
196
|
+
◀──────────────────────────────┤ │
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The CLI side dispatches each `tunnel_event` to user-registered callbacks via `TunnelClient.handleWsMessage`. Callbacks are stored per event type in arrays (`callbacks.stats`, `callbacks.disconnect`, etc.)
|
|
200
|
+
|
|
201
|
+
## 7. Foreground vs detached: SessionTracker
|
|
202
|
+
|
|
203
|
+
`SessionTracker` in `src/daemon/sessionTracker.ts` tracks ownership of each running tunnel.
|
|
204
|
+
|
|
205
|
+
```
|
|
206
|
+
ownership: Map<tunnelId, { sessionId, mode: "foreground" | "detached" }>
|
|
207
|
+
graceTimers: Map<tunnelId, NodeJS.Timeout>
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
State machine for a tunnel:
|
|
211
|
+
|
|
212
|
+
```
|
|
213
|
+
subscribe(mode="foreground")
|
|
214
|
+
┌──────────────────────────────────────────────┐
|
|
215
|
+
│ ▼
|
|
216
|
+
(no owner) FOREGROUND
|
|
217
|
+
│ ▲ │
|
|
218
|
+
│ │ │ WS session disconnects
|
|
219
|
+
│ │ ▼
|
|
220
|
+
│ │ GRACE (5000ms)
|
|
221
|
+
│ │ subscribe(mode="foreground") │
|
|
222
|
+
│ │ (another CLI re-attaches) │
|
|
223
|
+
│ │ ◀────────────────────────────────────────┤
|
|
224
|
+
│ │ │ timer expires
|
|
225
|
+
│ │ ▼
|
|
226
|
+
│ └─────── manager.stopTunnel(tunnelId) ◀────┘
|
|
227
|
+
│
|
|
228
|
+
│ subscribe(mode="detached")
|
|
229
|
+
│ or remote-management start
|
|
230
|
+
▼
|
|
231
|
+
DETACHED (session disconnect has no effect)
|
|
232
|
+
│
|
|
233
|
+
│ /tunnels/stop or daemon shutdown
|
|
234
|
+
▼
|
|
235
|
+
(removed)
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Key invariants:
|
|
239
|
+
|
|
240
|
+
- Foreground tunnels die 5 seconds after their WS session disconnects if nobody re-attaches. Implemented as `startGraceTimer` → `killOrphanedTunnel` in `SessionTracker`.
|
|
241
|
+
- Detached tunnels are immune to session disconnect and persist in `daemon-state.json` for crash recovery.
|
|
242
|
+
- `attach(tunnelId, sessionId, mode)` cancels any pending grace timer for that tunnel.
|
|
243
|
+
- `pinggy attach <name|id>` opens a fresh foreground subscription; if the tunnel was in detached mode, the call still succeeds, the timer is not armed (mode stays foreground while subscribed).
|
|
244
|
+
|
|
245
|
+
## 8. Crash recovery
|
|
246
|
+
|
|
247
|
+
`src/daemon/stateStore.ts` writes `daemon-state.json` after every tunnel start/stop. The schema:
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
interface DaemonState {
|
|
251
|
+
tunnels: DaemonStateTunnel[]; // {tunnelId, configId, name, origin, config, mode, startedAt}
|
|
252
|
+
lastUpdated: string;
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Two lifecycle paths:
|
|
257
|
+
|
|
258
|
+
- **Clean shutdown** (`/shutdown` or signal): `clearDaemonState()` deletes the file. Next daemon start sees nothing to recover.
|
|
259
|
+
- **Crash** (SIGKILL, OOM, panic): the file remains. Next daemon start calls `restoreCrashedTunnels()`, which restarts each `mode === "detached"` tunnel using the persisted config and stable `tunnelId` (so log files stay continuous). Foreground tunnels are not restored: there is no CLI to attach.
|
|
260
|
+
|
|
261
|
+
Auto-start tunnels are independent of crash recovery. They come from saved configs marked `autoStart: true` (`getAutoStartConfigs()`), not from the state file.
|
|
262
|
+
|
|
263
|
+
## 9. Tunnel lifecycle end-to-end
|
|
264
|
+
|
|
265
|
+
Foreground start (`pinggy start <name>`):
|
|
266
|
+
|
|
267
|
+
```
|
|
268
|
+
CLI: pinggy start my-tunnel
|
|
269
|
+
│
|
|
270
|
+
▼
|
|
271
|
+
subcommands.handleStart()
|
|
272
|
+
├─ resolveConfig("my-tunnel") → SavedTunnelConfig
|
|
273
|
+
├─ buildFinalConfig() with optional flag overrides
|
|
274
|
+
└─ startForegroundViaDaemon(finalConfig)
|
|
275
|
+
│
|
|
276
|
+
▼
|
|
277
|
+
initClient() → new TunnelClient() → ensureDaemon()
|
|
278
|
+
│ (spawns daemon if missing)
|
|
279
|
+
▼
|
|
280
|
+
client.handleStartV2(finalConfig) ── HTTP POST /tunnels/start-config
|
|
281
|
+
│ │
|
|
282
|
+
│ ▼
|
|
283
|
+
│ IPCServer.handleStartV2
|
|
284
|
+
│ │
|
|
285
|
+
│ ▼
|
|
286
|
+
│ TunnelManager.createTunnel + startTunnel
|
|
287
|
+
│ │
|
|
288
|
+
│ ▼
|
|
289
|
+
│ attachTunnelLogger(tid, origin, name)
|
|
290
|
+
│ │
|
|
291
|
+
│ ▼
|
|
292
|
+
│ SDK opens tunnel, returns urls
|
|
293
|
+
│ TunnelResponseV2 ◀───────────────────── 200 OK
|
|
294
|
+
▼
|
|
295
|
+
client.attach(tunnelId, "foreground") ── WS subscribe
|
|
296
|
+
│
|
|
297
|
+
▼
|
|
298
|
+
connectTui()
|
|
299
|
+
├─ TTY available + !noTui → blessed TUI
|
|
300
|
+
└─ else → plain stdout
|
|
301
|
+
│
|
|
302
|
+
▼
|
|
303
|
+
await TUI exit / SIGINT
|
|
304
|
+
│
|
|
305
|
+
▼
|
|
306
|
+
client.handleStop(tunnelId) ── HTTP POST /tunnels/stop
|
|
307
|
+
client.close() ── WS close
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Background start adds `-b` flag → `startBackgroundTunnels()`. The CLI exits immediately after the daemon confirms start; the tunnel keeps running.
|
|
311
|
+
|
|
312
|
+
Attach (`pinggy attach <name|id>`):
|
|
313
|
+
|
|
314
|
+
```
|
|
315
|
+
attachCommand.handleAttach()
|
|
316
|
+
├─ ensureDaemon()
|
|
317
|
+
├─ client.handleListV2()
|
|
318
|
+
├─ findTunnel(list, nameOrId) (id exact, id prefix, name, configId)
|
|
319
|
+
├─ client.attach(tunnelId, "foreground") (cancels detached grace timer? no, only foreground grace)
|
|
320
|
+
└─ connectTui()
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
The first time a detached tunnel is attached, the SessionTracker entry flips to `foreground` while the WS session is open. When the TUI quits, the user can choose to stop the tunnel (default in `connectTui`'s `onExit`).
|
|
324
|
+
|
|
325
|
+
## 10. Components by directory
|
|
326
|
+
|
|
327
|
+
```
|
|
328
|
+
src/
|
|
329
|
+
├── main.ts Entry dispatch (daemon-child / subcommand / legacy)
|
|
330
|
+
├── index.ts Library exports for embedding in Electron
|
|
331
|
+
├── types.ts Shared types: TunnelStatus, FinalConfig, ErrorResponse, enums
|
|
332
|
+
├── logger.ts, logger/ Winston root + per-tunnel transport attach/detach
|
|
333
|
+
│
|
|
334
|
+
├── cli/ Foreground CLI surface
|
|
335
|
+
│ ├── subcommands.ts Router for `config|start|stop|ps|attach|daemon|logs|log|restart`
|
|
336
|
+
│ ├── startCli.ts Foreground/background/multi/auto-start orchestration
|
|
337
|
+
│ ├── buildAndStartTunnel.ts Legacy single-tunnel path
|
|
338
|
+
│ ├── buildConfig.ts CLI flags + positionals → FinalConfig
|
|
339
|
+
│ ├── configStore.ts Persisted saved-config CRUD
|
|
340
|
+
│ ├── daemonCommands.ts `pinggy daemon start|stop|status|install-service|...`
|
|
341
|
+
│ ├── attachCommand.ts `pinggy attach`
|
|
342
|
+
│ ├── psCommand.ts `pinggy ps`
|
|
343
|
+
│ ├── stopCommand.ts `pinggy stop`
|
|
344
|
+
│ ├── restartCommand.ts `pinggy restart`
|
|
345
|
+
│ ├── logCommand.ts, logsCommand.ts `pinggy log`, `pinggy logs`
|
|
346
|
+
│ ├── options.ts, extendedOptions.ts, defaults.ts, help.ts
|
|
347
|
+
│
|
|
348
|
+
├── daemon/ Daemon process + IPC
|
|
349
|
+
│ ├── daemonChild.ts runDaemonChild() entry. Persistence, recovery, auto-start.
|
|
350
|
+
│ ├── daemonManager.ts getDaemonInfo, startDaemon, stopDaemon (spawn/discovery)
|
|
351
|
+
│ ├── ipcServer.ts HTTP + WS endpoints. Owns Routes map + WS sessions.
|
|
352
|
+
│ ├── ipcClient.ts Raw HTTP wrapper (no WS). Internal to tunnelClient.ts only.
|
|
353
|
+
│ ├── tunnelClient.ts PUBLIC facade. TunnelClient + DaemonTunnelHandler.
|
|
354
|
+
│ ├── wsProtocol.ts ClientMessage, TunnelEvent, TunnelEventPayloadMap.
|
|
355
|
+
│ ├── sessionTracker.ts Foreground/detached ownership + grace timer.
|
|
356
|
+
│ ├── stateStore.ts daemon-state.json read/write/clear.
|
|
357
|
+
│ └── serviceInstaller.ts systemd / launchd / Task Scheduler installers.
|
|
358
|
+
│
|
|
359
|
+
├── tunnel_manager/
|
|
360
|
+
│ └── TunnelManager.ts Singleton wrapping @pinggy/pinggy SDK. Listener fanout.
|
|
361
|
+
│
|
|
362
|
+
├── remote_management/ Remote control via Pinggy management WS
|
|
363
|
+
│ ├── remoteManagement.ts Connect/disconnect + state machine
|
|
364
|
+
│ ├── handler.ts TunnelHandler interface + TunnelOperations impl
|
|
365
|
+
│ ├── remote_schema.ts Zod schemas for V1 + V2 payloads
|
|
366
|
+
│ ├── websocket_handlers.ts, websocket_printer.ts
|
|
367
|
+
│
|
|
368
|
+
├── tui/ Foreground UI
|
|
369
|
+
│ ├── blessed/ blessed-based TUI for tunnels
|
|
370
|
+
│ ├── ink/ React/Ink prototypes
|
|
371
|
+
│ └── spinner/ Loading spinner widget
|
|
372
|
+
│
|
|
373
|
+
├── utils/ Cross-cutting helpers
|
|
374
|
+
│ ├── configDir.ts Per-OS config + log paths
|
|
375
|
+
│ ├── parseArgs.ts CLI arg parser
|
|
376
|
+
│ ├── printer.ts CLIPrinter singleton
|
|
377
|
+
│ ├── getFreePort.ts Free local port for web debugger
|
|
378
|
+
│ ├── FileServer.ts Static file serving
|
|
379
|
+
│ └── htmlTemplates.ts Built-in error pages
|
|
380
|
+
│
|
|
381
|
+
└── workers/
|
|
382
|
+
└── file_serve_worker.ts Worker thread for --serve
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
## 11. Key types
|
|
386
|
+
|
|
387
|
+
- `TunnelOrigin = "app" | "cli" | "remote"` (`src/tunnel_manager/TunnelManager.ts`). Set on every tunnel at creation; flows into log filenames.
|
|
388
|
+
- `ClientOrigin = "app" | "cli" | "remote"` (`src/daemon/ipcClient.ts`). Same values, used in the `X-Pinggy-Origin` header.
|
|
389
|
+
- `TunnelStateType` (`src/types.ts`): `idle | starting | running | live | closed | exited`.
|
|
390
|
+
- `FinalConfig` (`src/types.ts`): the SDK's `TunnelConfigurationV1` plus `conf?` and `saveconf?`. Used by `buildConfig.ts` and threaded through to the daemon.
|
|
391
|
+
- `TunnelResponseV2` (`src/remote_management/handler.ts`): `{tunnelid, remoteurls, tunnelconfig, status, stats, greetmsg?}`. The canonical wire shape.
|
|
392
|
+
- `DaemonInfo` (`src/daemon/daemonChild.ts`): `{pid, port, startedAt}`. The contents of `daemon.json`.
|
|
393
|
+
- `DaemonStateTunnel` (`src/daemon/stateStore.ts`): `{tunnelId, configId, name, origin, config, mode, startedAt}`. Crash recovery row.
|
|
394
|
+
|
|
395
|
+
## 12. Singletons and listener maps
|
|
396
|
+
|
|
397
|
+
The daemon process holds these singletons:
|
|
398
|
+
|
|
399
|
+
- `TunnelManager.getInstance()`. Wraps the SDK. Holds `Map<tunnelId, ManagedTunnel>`. All tunnel state lives here.
|
|
400
|
+
- `logger` (winston). Global root logger. Per-tunnel `File` transports are added/removed by `attachTunnelLogger` / `detachTunnelLogger` in `src/logger/tunnelLogger.ts`, gated by `isTunnelLoggingEnabled()`.
|
|
401
|
+
- `CLIPrinter`. Only used by CLI process for foreground output. Daemon does not print to stdout.
|
|
402
|
+
|
|
403
|
+
Listener registration pattern (used by TunnelManager and propagated by IPCServer):
|
|
404
|
+
|
|
405
|
+
```ts
|
|
406
|
+
Map<tunnelId, Map<listenerId, (...) => void>>
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
Register returns a `listenerId`; deregister takes it back. The IPCServer remembers `${type}:${listenerId}` strings in `session.listenerIds.get(tunnelId)` so a session can deregister everything cleanly on close.
|
|
410
|
+
|
|
411
|
+
## 13. Origin tagging and log file naming
|
|
412
|
+
|
|
413
|
+
When a tunnel is created, the IPCServer reads the `X-Pinggy-Origin` header and passes it to `TunnelManager.createTunnel(config, origin)`. The origin is stored on `ManagedTunnel.origin` and is used by `getTunnelLogPath(tunnelId, origin, name)`:
|
|
414
|
+
|
|
415
|
+
```
|
|
416
|
+
<origin>__<name>__<tunnelId>.log when name present
|
|
417
|
+
<origin>__<tunnelId>.log when nameless
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
Example: `cli__my-api__abc123.log`, `app__1700000000_xyz789.log`, `remote__staging__def456.log`.
|
|
421
|
+
|
|
422
|
+
This is how a shared daemon distinguishes tunnels from different clients on disk. The log viewers in the desktop app filter by the origin prefix.
|
|
423
|
+
|
|
424
|
+
## 14. Logging architecture
|
|
425
|
+
|
|
426
|
+
Three log artifacts:
|
|
427
|
+
|
|
428
|
+
| Log | Writer | Path | Lifetime |
|
|
429
|
+
| --------------- | ------------------------------------- | ----------------------------------------------------- | ----------------------------- |
|
|
430
|
+
| Application log | Electron host via `electron-log` | `<userData>/logs/` | Persistent, rolled |
|
|
431
|
+
| Daemon log | winston root inside daemon | `<logDir>/daemon.log` | Persistent, rolled |
|
|
432
|
+
| Tunnel log | winston `File` transport per tunnel | `<logDir>/tunnels/<origin>__<name>__<tunnelId>.log` | While tunnel runs (10MB × 3) |
|
|
433
|
+
|
|
434
|
+
Toggle: `setTunnelLoggingEnabled(false)` detaches every per-tunnel transport immediately. Tunnels keep running; nothing else changes. Re-enabling does not retroactively re-attach to running tunnels (transports are recreated on next tunnel start).
|
|
435
|
+
|
|
436
|
+
CLI logs (the short-lived foreground process) go to stdout by default. The daemon log captures everything inside the long-running process.
|
|
437
|
+
|
|
438
|
+
## 15. Remote management integration
|
|
439
|
+
|
|
440
|
+
`src/remote_management/remoteManagement.ts` opens a WebSocket to Pinggy's management endpoint and forwards tunnel commands. The receiver implements `TunnelHandler`. Two implementations exist:
|
|
441
|
+
|
|
442
|
+
- `TunnelOperations` (`src/remote_management/handler.ts`). Direct, in-process. Used when remote management runs **inside the daemon** (not the default in CLI).
|
|
443
|
+
- `DaemonTunnelHandler` (`src/daemon/tunnelClient.ts`). Proxy. Forwards every `handleStart`, `handleStop`, etc. over HTTP to the daemon. Used when remote management runs **inside the CLI process** (the current default).
|
|
444
|
+
|
|
445
|
+
`TunnelClient.forRemoteManagement()` returns a `DaemonTunnelHandler` with `origin = "remote"`, so tunnels created via remote management are tagged distinctly and logged separately.
|
|
446
|
+
|
|
447
|
+
Same code path as user-typed subcommands: the daemon does not know or care that the request came from remote management.
|
|
448
|
+
|
|
449
|
+
## 16. Service installation
|
|
450
|
+
|
|
451
|
+
`pinggy daemon install-service` writes a platform-specific service file and enables it:
|
|
452
|
+
|
|
453
|
+
- Linux: systemd user unit at `~/.config/systemd/user/pinggy.service`. `Type=simple`, runs `<binary> --_daemon-child` directly (no fork). `systemctl --user enable --now`.
|
|
454
|
+
- macOS: launchd `LaunchAgent` at `~/Library/LaunchAgents/io.pinggy.agent.plist`. `launchctl load`.
|
|
455
|
+
- Windows: Task Scheduler entry.
|
|
456
|
+
|
|
457
|
+
The installer resolves the binary via `resolveBinary()` in `src/daemon/serviceInstaller.ts`. Prefers `process.execPath` when running as a pkg binary, falls back to `which pinggy`, then to `node <script>`.
|
|
458
|
+
|
|
459
|
+
A system service gives restart-on-failure and start-at-login. The plain daemon mode does not.
|
|
460
|
+
|
|
461
|
+
## 17. Reference for AI agents
|
|
462
|
+
|
|
463
|
+
When making changes, these are the load-bearing files and the contracts that matter.
|
|
464
|
+
|
|
465
|
+
**Entry points**
|
|
466
|
+
|
|
467
|
+
- `src/main.ts` — argv dispatch. Adding a new top-level subcommand means updating `SUBCOMMANDS` in `src/cli/subcommands.ts` AND adding a handler. Updating the dispatch in `main.ts` is rarely needed.
|
|
468
|
+
- `src/daemon/daemonChild.ts` — daemon process entry. Anything that needs to run **once** when the daemon boots goes here.
|
|
469
|
+
|
|
470
|
+
**Adding a new IPC route**
|
|
471
|
+
|
|
472
|
+
1. Add the route key + handler in `IPCServer.registerRoutes()` (`src/daemon/ipcServer.ts`).
|
|
473
|
+
2. Add the wire method to `IPCClient` (`src/daemon/ipcClient.ts`).
|
|
474
|
+
3. Expose it on `TunnelClient` (`src/daemon/tunnelClient.ts`) — never let callers reach `IPCClient` directly.
|
|
475
|
+
4. If it streams events, add the event type to `DaemonEventType` and payload to `TunnelEventPayloadMap` in `src/daemon/wsProtocol.ts`, register the listener in `handleSubscribe`, deregister in `deregisterListeners`, dispatch in `TunnelClient.handleWsMessage`.
|
|
476
|
+
|
|
477
|
+
**Adding a new tunnel state**
|
|
478
|
+
|
|
479
|
+
If new state needs to survive a daemon crash, extend `DaemonStateTunnel` in `src/daemon/stateStore.ts` and update `restoreCrashedTunnels()` in `daemonChild.ts`. If it is only needed in memory, put it on `ManagedTunnel` in `src/tunnel_manager/TunnelManager.ts`.
|
|
480
|
+
|
|
481
|
+
**Foreground vs detached**
|
|
482
|
+
|
|
483
|
+
If you spawn a tunnel and want it to die when the CLI quits, subscribe with `mode: "foreground"`. If it should outlive the CLI, use `mode: "detached"` (and the daemon will write it to `daemon-state.json` for crash recovery).
|
|
484
|
+
|
|
485
|
+
**Origin**
|
|
486
|
+
|
|
487
|
+
Every tunnel must have a correct origin. The default in `parseOrigin()` is `cli`. If you add a new client surface, plumb a new origin value through `TunnelOrigin` and `ClientOrigin`. Origin affects log filenames; renaming an origin is a breaking change for log viewers.
|
|
488
|
+
|
|
489
|
+
**Singletons in tests**
|
|
490
|
+
|
|
491
|
+
`TunnelManager.instance = undefined` between tests, plus `jest.clearAllMocks()`. See `src/_tests_/` for examples. Use `@jest/globals` imports — there is no global `jest`.
|
|
492
|
+
|
|
493
|
+
**English style**
|
|
494
|
+
|
|
495
|
+
No em-dashes. Short phrases. Present tense. See `CLAUDE.md` § "English Style" before writing user-facing strings, error messages, or docs.
|
|
496
|
+
|
|
497
|
+
**Gotchas**
|
|
498
|
+
|
|
499
|
+
- Do not log to stdout from the daemon. The daemon's stdio is closed by `spawn({stdio: "ignore"})` on most platforms; writes will silently fail or fill an OS buffer.
|
|
500
|
+
- Do not call `process.exit()` inside daemon route handlers. The shutdown route is the only path that should terminate the process, and it does so via `setTimeout(() => process.exit(0), 200)` so the HTTP response can flush first.
|
|
501
|
+
- Do not import from `src/daemon/ipcClient.ts` outside `src/daemon/`. Use `TunnelClient` from `tunnelClient.ts`.
|
|
502
|
+
- The first CLI run on a fresh install will spawn the daemon and pay an 8-second worst-case latency. Subsequent runs reuse the daemon.
|
|
503
|
+
- `daemon.json` writes are atomic (write tmp, rename). Direct partial reads should not happen, but `getDaemonInfo()` still tolerates malformed JSON by returning `null` and triggering a respawn.
|
|
504
|
+
- The IPC server only listens on `127.0.0.1`. Do not change to `0.0.0.0` without adding authentication. The current design assumes loopback isolation.
|
|
505
|
+
|
|
506
|
+
## 18. Quick diagrams
|
|
507
|
+
|
|
508
|
+
### Start sequence (foreground, daemon already running)
|
|
509
|
+
|
|
510
|
+
```
|
|
511
|
+
user CLI Daemon SDK / TunnelManager
|
|
512
|
+
│ pinggy start api │ │
|
|
513
|
+
├──────────────────────▶ │
|
|
514
|
+
│ │ GET /ping (sanity) │
|
|
515
|
+
│ ├───────────────▶ │
|
|
516
|
+
│ │ ◀───────────── │ {status:"ok"} │
|
|
517
|
+
│ │ POST /tunnels/start-config │
|
|
518
|
+
│ ├───────────────▶│ createTunnel + start │
|
|
519
|
+
│ │ ├──────────────────────▶
|
|
520
|
+
│ │ │ urls │
|
|
521
|
+
│ │ ◀──────────────────────┤
|
|
522
|
+
│ │ 200 TunnelResponseV2 │
|
|
523
|
+
│ │ ◀──────────────│ │
|
|
524
|
+
│ │ ws connect → /ws │
|
|
525
|
+
│ │ ws subscribe(tid, "foreground") │
|
|
526
|
+
│ ├───────────────▶│ register N listeners │
|
|
527
|
+
│ │ tunnel_event "subscribed" │
|
|
528
|
+
│ │ ◀──────────────│ │
|
|
529
|
+
│ TUI render │ │ │
|
|
530
|
+
│ ◀────────────────────│ │ │
|
|
531
|
+
│ │ tunnel_event "stats" (repeated) │
|
|
532
|
+
│ │ ◀──────────────│ │
|
|
533
|
+
│ Ctrl+C │ │
|
|
534
|
+
├──────────────────────▶ POST /tunnels/stop │
|
|
535
|
+
│ ├───────────────▶│ stopTunnel │
|
|
536
|
+
│ │ ◀──────────────│ │
|
|
537
|
+
│ │ ws close │
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
### Crash and recovery
|
|
541
|
+
|
|
542
|
+
```
|
|
543
|
+
┌──────────────────────────────────┐
|
|
544
|
+
│ Daemon running, 2 detached │
|
|
545
|
+
│ daemon-state.json: [t1, t2] │
|
|
546
|
+
└──────────────────────────────────┘
|
|
547
|
+
│ SIGKILL / OOM
|
|
548
|
+
▼
|
|
549
|
+
┌──────────────────────────────────┐
|
|
550
|
+
│ Process gone, but state file │
|
|
551
|
+
│ still on disk │
|
|
552
|
+
└──────────────────────────────────┘
|
|
553
|
+
│ next pinggy command
|
|
554
|
+
▼
|
|
555
|
+
getDaemonInfo() returns null (PID dead) ──▶ unlinks daemon.json
|
|
556
|
+
│
|
|
557
|
+
▼
|
|
558
|
+
startDaemon() spawns new child
|
|
559
|
+
│
|
|
560
|
+
▼
|
|
561
|
+
runDaemonChild()
|
|
562
|
+
├─ writeDaemonInfo()
|
|
563
|
+
├─ restoreCrashedTunnels() ──▶ TunnelManager.createTunnel + startTunnel for t1, t2
|
|
564
|
+
│ using same tunnelid (log continuity)
|
|
565
|
+
├─ getAutoStartConfigs() ──▶ starts auto-start tunnels
|
|
566
|
+
└─ "Daemon ready"
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
### Two clients, one daemon
|
|
570
|
+
|
|
571
|
+
```
|
|
572
|
+
Time
|
|
573
|
+
│
|
|
574
|
+
│ 10:00 Electron app launches.
|
|
575
|
+
│ App finds no daemon → spawns. Daemon starts.
|
|
576
|
+
│ App opens tunnel "web" with origin=app.
|
|
577
|
+
│ log: app__web__t1.log
|
|
578
|
+
│
|
|
579
|
+
│ 10:05 User runs `pinggy ps` from terminal.
|
|
580
|
+
│ CLI finds daemon.json → connects (no spawn).
|
|
581
|
+
│ Both tunnels (including the App's) appear in ps.
|
|
582
|
+
│
|
|
583
|
+
│ 10:06 User runs `pinggy start api`.
|
|
584
|
+
│ Origin=cli. Foreground TUI opens.
|
|
585
|
+
│ log: cli__api__t2.log
|
|
586
|
+
│
|
|
587
|
+
│ 10:10 User Ctrl+C's the CLI tunnel.
|
|
588
|
+
│ CLI sends /tunnels/stop. Daemon stops t2.
|
|
589
|
+
│ Daemon keeps running. App's tunnel t1 unaffected.
|
|
590
|
+
│
|
|
591
|
+
│ 10:30 User closes Electron app.
|
|
592
|
+
│ App calls /shutdown? No — App leaves the daemon up
|
|
593
|
+
│ since CLI/remote may still use it.
|
|
594
|
+
│ Daemon keeps running with t1 still live.
|
|
595
|
+
▼
|
|
596
|
+
```
|
package/README.md
CHANGED
|
@@ -324,6 +324,7 @@ Extended options provide advanced controls. Specify them as positional values li
|
|
|
324
324
|
- x:reverseproxy Disable built-in reverse-proxy header injection.
|
|
325
325
|
- x:xff Add X-Forwarded-For.
|
|
326
326
|
- x:fullurl | x:fullrequesturl Include original request URL.
|
|
327
|
+
- x:haproxy | x:haproxy:v1 | x:haproxy:v2 Send HAProxy PROXY protocol header to the local server (default v1). TCP tunnels only.
|
|
327
328
|
- w:<cidr>[,<cidr>...] Whitelist IPs (IPv4 CIDR).
|
|
328
329
|
- k:<token> Set Bearer token(s) for auth (repeatable).
|
|
329
330
|
- b:<user:pass> Add Basic Auth credentials (repeatable).
|
|
@@ -338,6 +339,9 @@ Examples:
|
|
|
338
339
|
- Allow only a local subnet:
|
|
339
340
|
pinggy w:192.168.1.0/24 -l 8080
|
|
340
341
|
|
|
342
|
+
- Send the real client IP to a PROXY-protocol-aware backend (TCP tunnels only):
|
|
343
|
+
pinggy --type tcp x:haproxy:v2 -l 8080
|
|
344
|
+
|
|
341
345
|
To generate advanced CLI arguments, use [Configure from Pinggy.io](https://pinggy.io/)
|
|
342
346
|
|
|
343
347
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
TunnelAlreadyRunningError,
|
|
3
3
|
TunnelManager
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-PQSLSIDA.js";
|
|
5
5
|
import "./chunk-UB26QJ4T.js";
|
|
6
|
-
import "./chunk-
|
|
6
|
+
import "./chunk-P7WNBN7F.js";
|
|
7
7
|
import "./chunk-GBYF2H4H.js";
|
|
8
8
|
export {
|
|
9
9
|
TunnelAlreadyRunningError,
|