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.
@@ -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
+ ![1779362797675](image/ARCHITECTURE/1779362797675.png)
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-DLNUDW6G.js";
4
+ } from "./chunk-PQSLSIDA.js";
5
5
  import "./chunk-UB26QJ4T.js";
6
- import "./chunk-7G6SJEEA.js";
6
+ import "./chunk-P7WNBN7F.js";
7
7
  import "./chunk-GBYF2H4H.js";
8
8
  export {
9
9
  TunnelAlreadyRunningError,