unplugin-https-reverse-proxy 4.1.0-beta.3 → 4.1.0-beta.5

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.
@@ -2,8 +2,9 @@ import type { Socket } from 'node:net'
2
2
  import type { ClientHandshake } from './protocol'
3
3
  import type { HelperDeps, HelperRuntime } from './server'
4
4
  import { Buffer } from 'node:buffer'
5
- import { chmodSync, chownSync, rmSync } from 'node:fs'
5
+ import { chmodSync, chownSync, existsSync, readFileSync, rmSync } from 'node:fs'
6
6
  import { createServer } from 'node:net'
7
+ import { dirname, join } from 'node:path'
7
8
  import process from 'node:process'
8
9
  import { attachSocket, createHelperRuntime, createNodeHelperDeps } from './server'
9
10
 
@@ -26,6 +27,14 @@ export interface HelperBootstrapOptions {
26
27
  ownerGid?: number
27
28
  /** Caddy storage directory, handed back to the owner when Caddy stops. */
28
29
  caddyStoragePath?: string
30
+ /**
31
+ * How long the helper lingers with no clients before exiting.
32
+ *
33
+ * Kept short so a helper nobody is using does not stay on the machine as an
34
+ * idle root process. Within the same terminal a restart usually happens
35
+ * sooner than this and still reuses the running helper.
36
+ */
37
+ idleTimeoutMs?: number
29
38
  deps?: HelperDeps
30
39
  log?: (message: string) => void
31
40
  }
@@ -37,6 +46,9 @@ export interface RunningHelper {
37
46
 
38
47
  const HANDSHAKE_TIMEOUT_MS = 120_000
39
48
 
49
+ /** Default idle lifetime after the last client leaves. */
50
+ export const DEFAULT_IDLE_TIMEOUT_MS = 30_000
51
+
40
52
  /**
41
53
  * Runs the privileged side of the plugin: listens on a local socket, accepts
42
54
  * only a client presenting the shared token, then serves until that client
@@ -70,12 +82,26 @@ export async function startHelper(options: HelperBootstrapOptions): Promise<Runn
70
82
  catch {}
71
83
 
72
84
  const connections = new Set<Socket>()
85
+ const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS
86
+ let idleTimer: ReturnType<typeof setTimeout> | undefined
87
+
88
+ const stopIdleTimer = (): void => {
89
+ if (idleTimer) {
90
+ clearTimeout(idleTimer)
91
+ idleTimer = undefined
92
+ }
93
+ }
73
94
 
74
95
  const server = createServer((socket) => {
75
96
  let buffer = ''
76
97
  let authenticated = false
98
+ stopIdleTimer()
77
99
  connections.add(socket)
78
- socket.on('close', () => connections.delete(socket))
100
+ socket.on('close', () => {
101
+ connections.delete(socket)
102
+ if (connections.size === 0)
103
+ scheduleIdleExit()
104
+ })
79
105
 
80
106
  const timer = setTimeout(() => {
81
107
  if (!authenticated)
@@ -127,11 +153,41 @@ export async function startHelper(options: HelperBootstrapOptions): Promise<Runn
127
153
  socket.on('error', () => {})
128
154
  })
129
155
 
156
+ /**
157
+ * Exits once nobody needs the helper.
158
+ *
159
+ * Without this, every dev server that ever ran leaves a root process behind.
160
+ * The socket and token are removed too, but only while they still carry this
161
+ * process's identity: a newer helper may already own them.
162
+ */
163
+ function scheduleIdleExit(): void {
164
+ if (idleTimer || connections.size > 0)
165
+ return
166
+
167
+ idleTimer = setTimeout(() => {
168
+ idleTimer = undefined
169
+ if (connections.size > 0)
170
+ return
171
+
172
+ log('no clients left; shutting down')
173
+ removeOwnSocket(socketPath, token)
174
+ server.close(() => process.exit(0))
175
+ // A listener with no clients closes synchronously, but keep a backstop so
176
+ // the process cannot linger if a close callback never arrives.
177
+ setTimeout(() => process.exit(0), 1000).unref?.()
178
+ }, idleTimeoutMs)
179
+ idleTimer.unref?.()
180
+ }
181
+
130
182
  await new Promise<void>((resolve, reject) => {
131
183
  server.once('error', reject)
132
184
  server.listen(socketPath, () => resolve())
133
185
  })
134
186
 
187
+ // A helper nobody ever connects to is as useless as one whose clients left:
188
+ // start the timer now so a failed handshake does not strand it.
189
+ scheduleIdleExit()
190
+
135
191
  try {
136
192
  // The client is not root: hand the socket back to the developer and keep it
137
193
  // private to them. Without the chown, `connect()` fails with EACCES and the
@@ -163,6 +219,31 @@ export async function startHelper(options: HelperBootstrapOptions): Promise<Runn
163
219
  }
164
220
  }
165
221
 
222
+ /**
223
+ * Removes this helper's socket and token, but only if they are still its own.
224
+ *
225
+ * A newer helper may have taken both over; deleting them then would break a
226
+ * session this process never served.
227
+ */
228
+ function removeOwnSocket(socketPath: string, token: string): void {
229
+ if (process.platform === 'win32')
230
+ return
231
+
232
+ try {
233
+ const tokensDir = dirname(socketPath)
234
+ const tokenFile = join(tokensDir, 'helper.token')
235
+ const current = existsSync(tokenFile) ? readFileSync(tokenFile, 'utf8').trim() : undefined
236
+ if (current === token)
237
+ rmSync(tokenFile, { force: true })
238
+ }
239
+ catch {}
240
+
241
+ try {
242
+ rmSync(socketPath, { force: true })
243
+ }
244
+ catch {}
245
+ }
246
+
166
247
  /** Sockets left behind by a killed helper cannot be reused; remove them first. */
167
248
  export function removeStaleSocket(socketPath: string): void {
168
249
  if (process.platform === 'win32')
@@ -70,10 +70,10 @@ export function describePasswordReason(target: string, https: boolean): string[]
70
70
  * Returns `denied` when the user dismissed the prompt, so the caller can keep
71
71
  * the dev server running instead of treating it as a crash.
72
72
  */
73
- export function authenticateSudo(
73
+ export async function authenticateSudo(
74
74
  sudoCommand = 'sudo',
75
75
  options: { target?: string, https?: boolean } = {},
76
- ): { ok: boolean, denied?: boolean, message?: string } {
76
+ ): Promise<{ ok: boolean, denied?: boolean, message?: string }> {
77
77
  if (platform() === 'win32')
78
78
  return { ok: true }
79
79
 
@@ -86,16 +86,53 @@ export function authenticateSudo(
86
86
  process.stderr.write('\n')
87
87
  }
88
88
 
89
- if (spawnSync(sudoCommand, ['-v'], { stdio: 'inherit' }).status === 0)
90
- return { ok: true }
89
+ // `sudo -v` inherits the terminal, so Ctrl+C reaches this process too. Without
90
+ // a handler the default action kills the dev server, which reads as "the
91
+ // proxy crashed my dev server" when the user only meant to skip it.
92
+ let interrupted = false
93
+ const onSigint = (): void => {
94
+ interrupted = true
95
+ }
96
+ process.on('SIGINT', onSigint)
97
+
98
+ try {
99
+ const { status, signal } = await runPrompt(sudoCommand)
100
+ if (status === 0)
101
+ return { ok: true }
91
102
 
92
- return {
93
- ok: false,
94
- denied: true,
95
- message: 'administrator permission is required to edit the hosts file and bind ports 80/443',
103
+ // `sudo` exits 1 on a wrong password and dies by the signal on Ctrl+C.
104
+ // Both are checked: a real Ctrl+C reaches this process and the child
105
+ // together, while a signal aimed at the child alone only shows up here.
106
+ const cancelled = interrupted || signal === 'SIGINT'
107
+ return {
108
+ ok: false,
109
+ denied: true,
110
+ message: cancelled
111
+ ? 'administrator permission was cancelled'
112
+ : 'administrator permission is required to edit the hosts file and bind ports 80/443',
113
+ }
114
+ }
115
+ finally {
116
+ process.off('SIGINT', onSigint)
96
117
  }
97
118
  }
98
119
 
120
+ /**
121
+ * Runs `sudo -v` with the terminal attached.
122
+ *
123
+ * Spawned asynchronously rather than with `spawnSync`: the synchronous form
124
+ * blocks the event loop, so the SIGINT handler above never runs and the exit
125
+ * looks like a plain failure instead of a cancellation.
126
+ */
127
+ function runPrompt(sudoCommand: string): Promise<{ status: number | null, signal: NodeJS.Signals | null }> {
128
+ return new Promise((resolve) => {
129
+ const child = spawn(sudoCommand, ['-v'], { stdio: 'inherit' })
130
+
131
+ child.on('error', () => resolve({ status: 1, signal: null }))
132
+ child.on('exit', (status, signal) => resolve({ status, signal }))
133
+ })
134
+ }
135
+
99
136
  /** The user dismissed the password prompt; this is a choice, not a failure. */
100
137
  export class PrivilegeDeniedError extends Error {
101
138
  constructor(message: string) {
@@ -132,7 +169,7 @@ export async function startPrivilegedHelper(options: LaunchOptions): Promise<Pri
132
169
  } = options
133
170
 
134
171
  if (!skipAuth && !direct) {
135
- const auth = authenticateSudo(sudoCommand, options.reason)
172
+ const auth = await authenticateSudo(sudoCommand, options.reason)
136
173
  if (!auth.ok) {
137
174
  const message = auth.message ?? 'administrator permission is required'
138
175
  throw auth.denied ? new PrivilegeDeniedError(message) : new Error(message)
@@ -46,6 +46,8 @@ const FLUSH_FALLBACK = [{ file: 'killall', args: ['-HUP', 'mDNSResponder'] }] as
46
46
  export interface CaddyChild {
47
47
  pid?: number
48
48
  kill: (signal?: NodeJS.Signals) => boolean
49
+ /** False once the process has exited; undefined when unknown. */
50
+ running?: boolean
49
51
  }
50
52
 
51
53
  /**
@@ -60,7 +62,8 @@ export interface HelperDeps {
60
62
  readHosts: () => string
61
63
  writeHosts: (content: string) => void
62
64
  flushDns: () => Promise<void>
63
- freePort: (port: number) => Promise<number[]>
65
+ /** Releases a port; never kills the pid in `keepPid`. */
66
+ freePort: (port: number, keepPid?: number) => Promise<number[]>
64
67
  /** Caddy binary and the Caddyfile it must load, both fixed at start-up. */
65
68
  caddyBin: string
66
69
  caddyConfigPath: string
@@ -91,7 +94,8 @@ async function flushDnsBestEffort(deps: HelperDeps): Promise<void> {
91
94
  }
92
95
 
93
96
  export interface HelperRuntime {
94
- handle: (request: HelperRequest) => Promise<HelperResponse>
97
+ /** `client` identifies the caller so shared host mappings are reference-counted. */
98
+ handle: (request: HelperRequest, client?: object) => Promise<HelperResponse>
95
99
  /** Registers a connected client so Caddy output reaches it. */
96
100
  attach: (client: object) => void
97
101
  /** Last client gone: stop Caddy and drop the hosts entries this plugin wrote. */
@@ -114,6 +118,37 @@ export function createHelperRuntime(deps: HelperDeps): HelperRuntime {
114
118
  let generation = 0
115
119
  let caddy: CaddyChild | undefined
116
120
 
121
+ // Which client asked for which host. Two projects may map the same domain, so
122
+ // a mapping is only removed once its last holder is gone.
123
+ const hostOwners = new Map<string, Set<object>>()
124
+
125
+ const hostId = (ip: string, host: string): string => `${ip} ${host.toLowerCase()}`
126
+
127
+ function addHostOwner(client: object | undefined, ip: string, host: string): void {
128
+ const id = hostId(ip, host)
129
+ let owners = hostOwners.get(id)
130
+ if (!owners) {
131
+ owners = new Set()
132
+ hostOwners.set(id, owners)
133
+ }
134
+ owners.add(client ?? deps)
135
+ }
136
+
137
+ /** True when no other client still needs this mapping. */
138
+ function releaseHostOwner(client: object | undefined, ip: string, host: string): boolean {
139
+ const id = hostId(ip, host)
140
+ const owners = hostOwners.get(id)
141
+ if (!owners)
142
+ return true
143
+
144
+ owners.delete(client ?? deps)
145
+ if (owners.size > 0)
146
+ return false
147
+
148
+ hostOwners.delete(id)
149
+ return true
150
+ }
151
+
117
152
  const broadcast = (event: HelperEvent): void => {
118
153
  const line = serializeMessage(event)
119
154
  for (const client of clients) {
@@ -183,7 +218,7 @@ export function createHelperRuntime(deps: HelperDeps): HelperRuntime {
183
218
  }
184
219
  }
185
220
 
186
- async function handle(request: HelperRequest): Promise<HelperResponse> {
221
+ async function handle(request: HelperRequest, client?: object): Promise<HelperResponse> {
187
222
  try {
188
223
  switch (request.method) {
189
224
  case 'ping':
@@ -197,6 +232,7 @@ export function createHelperRuntime(deps: HelperDeps): HelperRuntime {
197
232
  const edited = addHost(deps.readHosts(), ip, host)
198
233
  if (edited.changed)
199
234
  deps.writeHosts(edited.content)
235
+ addHostOwner(client, ip, host)
200
236
  await flushDnsBestEffort(deps)
201
237
  return { id: request.id, ok: true }
202
238
  }
@@ -206,6 +242,12 @@ export function createHelperRuntime(deps: HelperDeps): HelperRuntime {
206
242
  if (!isSafeIp(ip) || !isSafeHost(host))
207
243
  return { id: request.id, ok: false, error: { message: 'invalid hosts entry' } }
208
244
 
245
+ // Another project may still need this domain; only the last holder
246
+ // removes the mapping.
247
+ const last = releaseHostOwner(client, ip, host)
248
+ if (!last)
249
+ return { id: request.id, ok: true, result: { kept: true } }
250
+
209
251
  const edited = removeHost(deps.readHosts(), ip, host)
210
252
  if (edited.changed)
211
253
  deps.writeHosts(edited.content)
@@ -218,16 +260,20 @@ export function createHelperRuntime(deps: HelperDeps): HelperRuntime {
218
260
  if (!isAllowedFreePort(port))
219
261
  return { id: request.id, ok: false, error: { message: `port ${port} is not releasable` } }
220
262
 
221
- const killed = await deps.freePort(port)
263
+ // Caddy belongs to this helper, so a port sweep must never kill it:
264
+ // the pid is the one serving every other project's domains.
265
+ const killed = await deps.freePort(port, caddy?.pid)
222
266
  return { id: request.id, ok: true, result: { killed } }
223
267
  }
224
268
 
225
269
  case 'caddy.start': {
226
- if (caddy)
227
- await stopCaddy()
270
+ // Idempotent: several dev servers share one Caddy, so a second start
271
+ // must attach to the running process instead of restarting it.
272
+ if (caddy && caddy.running !== false)
273
+ return { id: request.id, ok: true, result: { pid: caddy.pid, started: false } }
228
274
 
229
275
  caddy = deps.startCaddy(broadcast)
230
- return { id: request.id, ok: true, result: { pid: caddy.pid } }
276
+ return { id: request.id, ok: true, result: { pid: caddy.pid, started: true } }
231
277
  }
232
278
 
233
279
  case 'caddy.stop': {
@@ -260,6 +306,27 @@ export function createHelperRuntime(deps: HelperDeps): HelperRuntime {
260
306
  },
261
307
  async detach(client) {
262
308
  clients.delete(client)
309
+
310
+ // Drop only the mappings this client was the last holder of, so a project
311
+ // that exits leaves the domains another project still serves alone.
312
+ for (const [id, owners] of [...hostOwners]) {
313
+ if (!owners.delete(client) || owners.size > 0)
314
+ continue
315
+
316
+ hostOwners.delete(id)
317
+ const [ip, host] = id.split(' ')
318
+ try {
319
+ const edited = removeHost(deps.readHosts(), ip, host)
320
+ if (edited.changed) {
321
+ deps.writeHosts(edited.content)
322
+ await flushDnsBestEffort(deps)
323
+ }
324
+ }
325
+ catch (error) {
326
+ deps.log(`hosts cleanup failed: ${(error as Error).message}`)
327
+ }
328
+ }
329
+
263
330
  if (clients.size === 0)
264
331
  await dispose()
265
332
  },
@@ -367,7 +434,7 @@ export function createNodeHelperDeps(options: NodeHelperDepsOptions): HelperDeps
367
434
 
368
435
  throw lastError ?? new Error('no DNS cache flush command for this platform')
369
436
  },
370
- freePort: async (port) => {
437
+ freePort: async (port, keepPid) => {
371
438
  // `lsof` exits with 1 when nothing matches, which is the normal case for
372
439
  // a free port; only its stdout carries the answer.
373
440
  const pids = platform() === 'win32'
@@ -376,7 +443,9 @@ export function createNodeHelperDeps(options: NodeHelperDepsOptions): HelperDeps
376
443
 
377
444
  const killed: number[] = []
378
445
  for (const pid of pids) {
379
- if (pid === process.pid)
446
+ // Never kill the helper itself or the Caddy it owns: that Caddy serves
447
+ // every project sharing this helper, not just the caller's.
448
+ if (pid === process.pid || (keepPid !== undefined && pid === keepPid))
380
449
  continue
381
450
  try {
382
451
  process.kill(pid, 'SIGTERM')
@@ -394,16 +463,28 @@ export function createNodeHelperDeps(options: NodeHelperDepsOptions): HelperDeps
394
463
  caddyStoragePath: options.caddyStoragePath,
395
464
  storageOwner: options.storageOwner,
396
465
  startCaddy: (emit) => {
397
- const child = spawn(options.caddyBin, ['run', '--adapter', 'caddyfile', '--config', options.caddyConfigPath], {
466
+ // `--watch` makes Caddy reload when another project rewrites the shared
467
+ // Caddyfile. Without it the second project's route never takes effect.
468
+ const child = spawn(options.caddyBin, ['run', '--adapter', 'caddyfile', '--config', options.caddyConfigPath, '--watch'], {
398
469
  cwd: options.caddyCwd ?? process.cwd(),
399
470
  env: { ...process.env, ...options.caddyEnv },
400
471
  stdio: ['ignore', 'pipe', 'pipe'],
401
472
  })
473
+ const handle: CaddyChild & { running?: boolean } = {
474
+ pid: child.pid,
475
+ kill: signal => child.kill(signal),
476
+ running: true,
477
+ }
402
478
  child.stdout?.on('data', (data: Buffer) => emit({ type: 'event', kind: 'caddy.stdout', data: data.toString() }))
403
479
  child.stderr?.on('data', (data: Buffer) => emit({ type: 'event', kind: 'caddy.stderr', data: data.toString() }))
404
- child.on('exit', (code, signal) => emit({ type: 'event', kind: 'caddy.exit', code, signal }))
405
- caddy = child as unknown as CaddyChild
406
- return caddy
480
+ child.on('exit', (code, signal) => {
481
+ // Recorded before broadcasting so a later `caddy.start` knows it has to
482
+ // spawn again rather than reuse a dead process.
483
+ handle.running = false
484
+ emit({ type: 'event', kind: 'caddy.exit', code, signal })
485
+ })
486
+ caddy = handle
487
+ return handle
407
488
  },
408
489
  stopCaddy: async () => {
409
490
  const current = caddy
@@ -448,7 +529,7 @@ export function attachSocket(socket: Socket, runtime: HelperRuntime, log: (messa
448
529
  continue
449
530
  }
450
531
 
451
- void runtime.handle(request).then((response) => {
532
+ void runtime.handle(request, socket).then((response) => {
452
533
  if (!socket.destroyed)
453
534
  socket.write(serializeMessage(response))
454
535
  })
@@ -4,6 +4,7 @@ import { existsSync } from 'node:fs'
4
4
  import { readFile, writeFile } from 'node:fs/promises'
5
5
  import { platform } from 'node:os'
6
6
  import { join } from 'node:path'
7
+ import * as lockfile from 'proper-lockfile'
7
8
  import { connectExistingHelper, createHelperToken, startPrivilegedHelper } from './launch'
8
9
 
9
10
  export interface SessionScope {
@@ -50,26 +51,50 @@ export function sessionSocketPath(runtimeDir: string): string {
50
51
  * Connects to the helper already serving this runtime directory, or starts a
51
52
  * new one. Reuse matters for multi-instance dev servers: the second instance
52
53
  * attaches to the running Caddy instead of asking for a password again.
54
+ *
55
+ * The check-then-start sequence runs under a lock. Two projects starting at the
56
+ * same moment would otherwise both fail to find a socket, both spawn a helper,
57
+ * and the second would delete the first one's socket while it was still
58
+ * serving.
53
59
  */
54
60
  export async function openHelperSession(scope: SessionScope): Promise<OpenedSession> {
55
61
  const socketPath = sessionSocketPath(scope.runtimeDir)
62
+ const release = await acquireStartLock(scope.runtimeDir)
56
63
 
57
- if (existsSync(socketPath) || platform() === 'win32') {
58
- const token = await readToken(scope.runtimeDir)
59
- if (token) {
60
- const reused = await connectExistingHelper(socketPath, token)
61
- if (reused) {
62
- return {
63
- client: reused,
64
- reused: true,
65
- close: async () => {
66
- await reused.close()
67
- },
68
- }
69
- }
70
- }
64
+ try {
65
+ const reused = await tryReuse(socketPath, scope)
66
+ if (reused)
67
+ return reused
68
+
69
+ return await startNew(socketPath, scope)
71
70
  }
71
+ finally {
72
+ release()
73
+ }
74
+ }
75
+
76
+ async function tryReuse(socketPath: string, scope: SessionScope): Promise<OpenedSession | undefined> {
77
+ if (!existsSync(socketPath) && platform() !== 'win32')
78
+ return undefined
79
+
80
+ const token = await readToken(scope.runtimeDir)
81
+ if (!token)
82
+ return undefined
83
+
84
+ const reused = await connectExistingHelper(socketPath, token)
85
+ if (!reused)
86
+ return undefined
72
87
 
88
+ return {
89
+ client: reused,
90
+ reused: true,
91
+ close: async () => {
92
+ await reused.close()
93
+ },
94
+ }
95
+ }
96
+
97
+ async function startNew(socketPath: string, scope: SessionScope): Promise<OpenedSession> {
73
98
  const token = createHelperToken()
74
99
  const started = await startPrivilegedHelper({
75
100
  socketPath,
@@ -98,6 +123,30 @@ export async function openHelperSession(scope: SessionScope): Promise<OpenedSess
98
123
  }
99
124
  }
100
125
 
126
+ /**
127
+ * Serialises "is a helper running? if not, start one" across processes.
128
+ *
129
+ * The lock file is separate from the socket: the socket only exists once a
130
+ * helper is listening, while this lock guards the window before that.
131
+ */
132
+ async function acquireStartLock(runtimeDir: string): Promise<() => void> {
133
+ const lockPath = join(runtimeDir, 'helper.start.lock')
134
+ try {
135
+ await writeFile(lockPath, '', { flag: 'a' })
136
+ }
137
+ catch {}
138
+
139
+ const release = await lockfile.lock(lockPath, {
140
+ realpath: false,
141
+ stale: 30_000,
142
+ retries: { retries: 100, minTimeout: 50, maxTimeout: 200 },
143
+ })
144
+
145
+ return () => {
146
+ void release().catch(() => {})
147
+ }
148
+ }
149
+
101
150
  async function readToken(runtimeDir: string): Promise<string | undefined> {
102
151
  try {
103
152
  const content = await readFile(tokenPath(runtimeDir), 'utf8')