unplugin-https-reverse-proxy 4.1.0-beta.2 → 4.1.0-beta.4

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/src/index.ts CHANGED
@@ -7,6 +7,8 @@ import c from 'picocolors'
7
7
  import { createUnplugin } from 'unplugin'
8
8
  import { CaddyInstant } from './caddy'
9
9
  import { hmrSseBridgeClientScript, setupHmrSseBridge } from './hmr-sse-bridge'
10
+ import { isPrivilegeDenied, markPrivilegeDenied } from './privilege-state'
11
+ import { PrivilegeDeniedError } from './privileged/launch'
10
12
  import { consola, generateQrcode, getIpv4List, once } from './utils'
11
13
 
12
14
  let config: ResolvedConfig
@@ -62,10 +64,15 @@ export const vitePrintUrls = once((
62
64
  ) => {
63
65
  _printUrls?.()
64
66
 
67
+ // Declined earlier in this process; a hot restart must not ask again.
68
+ if (isPrivilegeDenied())
69
+ return
70
+
65
71
  if (caddy)
66
72
  return
67
- caddy = new CaddyInstant()
68
- caddy.run(source, target, {
73
+ const instance = new CaddyInstant()
74
+ caddy = instance
75
+ instance.run(source, target, {
69
76
  base,
70
77
  ...options,
71
78
  }).then(async () => {
@@ -73,6 +80,16 @@ export const vitePrintUrls = once((
73
80
 
74
81
  consola.success(` ${c.green('➜')} ${c.bold('run caddy reverse proxy success')}: ${colorUrl(`${options.https ? 'https' : 'http'}://${target}${base}`)}`)
75
82
  }).catch((e) => {
83
+ // Declining the password is a choice, not a crash: drop the half-started
84
+ // instance so a later attempt can prompt again.
85
+ if (caddy === instance)
86
+ caddy = undefined as unknown as CaddyInstant
87
+ if (e instanceof PrivilegeDeniedError) {
88
+ markPrivilegeDenied()
89
+ consola.warn('reverse proxy skipped: administrator permission was not granted.')
90
+ consola.info('Restart the dev server to enable it again.')
91
+ return
92
+ }
76
93
  throw e
77
94
  })
78
95
  })
@@ -168,6 +185,9 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
168
185
  }
169
186
  const source = `${devServer.host}:${devServer.port}`
170
187
 
188
+ if (isPrivilegeDenied())
189
+ return
190
+
171
191
  if (caddy)
172
192
  return
173
193
 
@@ -195,9 +215,10 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
195
215
  options.https = false
196
216
  }
197
217
 
198
- caddy = new CaddyInstant()
218
+ const instance = new CaddyInstant()
219
+ caddy = instance
199
220
 
200
- caddy.run(source, target, {
221
+ instance.run(source, target, {
201
222
  base,
202
223
  ...options,
203
224
  }).then(() => {
@@ -207,6 +228,14 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
207
228
  consola.success(` ${c.green('➜')} ${c.bold('run caddy reverse proxy success')}: ${colorUrl(`${options.https ? 'https' : 'http'}://${target}${base}`)}`)
208
229
  })
209
230
  }).catch((e) => {
231
+ if (caddy === instance)
232
+ caddy = undefined as unknown as CaddyInstant
233
+ if (e instanceof PrivilegeDeniedError) {
234
+ markPrivilegeDenied()
235
+ consola.warn('reverse proxy skipped: administrator permission was not granted.')
236
+ consola.info('Restart the dev server to enable it again.')
237
+ return
238
+ }
210
239
  throw e
211
240
  })
212
241
  },
@@ -243,10 +272,11 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
243
272
 
244
273
  const source = `${devServer.host}:${devServer.port}`
245
274
 
246
- if (caddy)
275
+ if (isPrivilegeDenied())
247
276
  return
248
277
 
249
- caddy = new CaddyInstant()
278
+ if (caddy)
279
+ return
250
280
 
251
281
  let base = '/'
252
282
  const publicPath = compiler.options.output?.publicPath as unknown
@@ -258,7 +288,10 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
258
288
  if (base !== '/' && !base.endsWith('/'))
259
289
  base = `${base}/`
260
290
 
261
- caddy.run(source, target, {
291
+ const instance = new CaddyInstant()
292
+ caddy = instance
293
+
294
+ instance.run(source, target, {
262
295
  base,
263
296
  ...options,
264
297
  }).then(async () => {
@@ -266,6 +299,14 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
266
299
 
267
300
  consola.success(` ${c.green('➜')} ${c.bold('run caddy reverse proxy success')}: ${colorUrl(`${options.https ? 'https' : 'http'}://${target}${base}`)}`)
268
301
  }).catch((e) => {
302
+ if (caddy === instance)
303
+ caddy = undefined as unknown as CaddyInstant
304
+ if (e instanceof PrivilegeDeniedError) {
305
+ markPrivilegeDenied()
306
+ consola.warn('reverse proxy skipped: administrator permission was not granted.')
307
+ consola.info('Restart the dev server to enable it again.')
308
+ return
309
+ }
269
310
  throw e
270
311
  })
271
312
  },
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Whether the user dismissed the password prompt in this process.
3
+ *
4
+ * Kept in its own module so the flag outlives a hot restart. Vite rebuilds the
5
+ * dev server in place (`Object.assign(server, newServer)`) rather than starting
6
+ * a new process, so module scope is the only place that distinguishes "saved
7
+ * the config again" from "started the dev server again".
8
+ */
9
+ let denied = false
10
+
11
+ /** Records that the user declined; every entry point checks this first. */
12
+ export function markPrivilegeDenied(): void {
13
+ denied = true
14
+ }
15
+
16
+ export function isPrivilegeDenied(): boolean {
17
+ return denied
18
+ }
19
+
20
+ /** Test hook: restores the initial state between cases. */
21
+ export function resetPrivilegeDenied(): void {
22
+ denied = false
23
+ }
@@ -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')
@@ -1,5 +1,5 @@
1
1
  import { existsSync } from 'node:fs'
2
- import { join } from 'node:path'
2
+ import { dirname, join } from 'node:path'
3
3
  import process from 'node:process'
4
4
 
5
5
  export interface HelperEntry {
@@ -26,20 +26,48 @@ export interface ResolveHelperEntryOptions {
26
26
  * Published builds ship a compiled `privileged-helper.js` and start it with
27
27
  * node; running from the repository uses `tsx` so the TypeScript source works
28
28
  * without a build step.
29
+ *
30
+ * The running module sits in a subdirectory (`src/caddy`, `src/privileged`),
31
+ * while the helper lives one level up in `src`, so both lookups walk up instead
32
+ * of assuming a fixed depth.
29
33
  */
30
34
  export function resolveHelperEntry(options: ResolveHelperEntryOptions): HelperEntry {
31
35
  const { moduleDir, nodeBin = process.execPath, exists = existsSync } = options
32
36
 
33
37
  for (const name of ['privileged-helper.js', 'privileged-helper.cjs', 'privileged-helper.mjs']) {
34
- const candidate = join(moduleDir, name)
35
- if (exists(candidate))
38
+ const candidate = findUp(moduleDir, name, exists)
39
+ if (candidate)
36
40
  return { entry: nodeBin, args: [candidate] }
37
41
  }
38
42
 
39
- const source = join(moduleDir, 'privileged-helper.ts')
40
- const tsxCli = join(moduleDir, '../node_modules/tsx/dist/cli.mjs')
41
- if (exists(source) && exists(tsxCli))
43
+ const source = findUp(moduleDir, 'privileged-helper.ts', exists)
44
+ const tsxCli = findUp(moduleDir, 'node_modules/tsx/dist/cli.mjs', exists)
45
+ if (source && tsxCli)
42
46
  return { entry: nodeBin, args: [tsxCli, source] }
43
47
 
44
48
  throw new Error(`privileged helper entry not found in ${moduleDir}. Build the package first.`)
45
49
  }
50
+
51
+ /**
52
+ * Walks up from `from` looking for a relative path.
53
+ *
54
+ * Stops at the filesystem root so a helper outside the project is never picked
55
+ * up by accident.
56
+ */
57
+ function findUp(
58
+ from: string,
59
+ relative: string,
60
+ exists: (path: string) => boolean,
61
+ ): string | undefined {
62
+ let current = from
63
+ while (true) {
64
+ const candidate = join(current, relative)
65
+ if (exists(candidate))
66
+ return candidate
67
+
68
+ const parent = dirname(current)
69
+ if (parent === current)
70
+ return undefined
71
+ current = parent
72
+ }
73
+ }
@@ -39,6 +39,25 @@ export interface LaunchOptions {
39
39
  caddyBin: string
40
40
  /** Caddyfile the helper is allowed to load. */
41
41
  caddyConfigPath: string
42
+ /** Shown before the password prompt so the request is not a surprise. */
43
+ reason?: { target: string, https: boolean }
44
+ }
45
+
46
+ /**
47
+ * Explains why a password is about to be requested. Without this the prompt
48
+ * appears out of nowhere in the middle of `npm run dev` and looks like something
49
+ * went wrong.
50
+ */
51
+ export function describePasswordReason(target: string, https: boolean): string[] {
52
+ const port = https ? '443' : '80'
53
+ return [
54
+ `reverse proxy needs administrator permission for ${target}`,
55
+ ` · bind port ${port}${https ? ` for https://${target}` : ''}`,
56
+ ` · add ${target} to the hosts file`,
57
+ '',
58
+ 'This is the only prompt; the rest of the dev server keeps running as you.',
59
+ 'Press Ctrl+C at the prompt to skip the proxy and keep the dev server.',
60
+ ]
42
61
  }
43
62
 
44
63
  /**
@@ -47,23 +66,44 @@ export interface LaunchOptions {
47
66
  * The credential cache is keyed by terminal, so a later `sudo -n` only finds
48
67
  * this entry when the child stays in the same session. That is why the helper
49
68
  * is spawned without `detached`.
69
+ *
70
+ * Returns `denied` when the user dismissed the prompt, so the caller can keep
71
+ * the dev server running instead of treating it as a crash.
50
72
  */
51
- export function authenticateSudo(sudoCommand = 'sudo'): { ok: boolean, message?: string } {
73
+ export function authenticateSudo(
74
+ sudoCommand = 'sudo',
75
+ options: { target?: string, https?: boolean } = {},
76
+ ): { ok: boolean, denied?: boolean, message?: string } {
52
77
  if (platform() === 'win32')
53
78
  return { ok: true }
54
79
 
55
80
  if (spawnSync(sudoCommand, ['-n', 'true'], { stdio: 'ignore' }).status === 0)
56
81
  return { ok: true }
57
82
 
83
+ if (options.target) {
84
+ for (const line of describePasswordReason(options.target, options.https ?? false))
85
+ process.stderr.write(`${line}\n`)
86
+ process.stderr.write('\n')
87
+ }
88
+
58
89
  if (spawnSync(sudoCommand, ['-v'], { stdio: 'inherit' }).status === 0)
59
90
  return { ok: true }
60
91
 
61
92
  return {
62
93
  ok: false,
94
+ denied: true,
63
95
  message: 'administrator permission is required to edit the hosts file and bind ports 80/443',
64
96
  }
65
97
  }
66
98
 
99
+ /** The user dismissed the password prompt; this is a choice, not a failure. */
100
+ export class PrivilegeDeniedError extends Error {
101
+ constructor(message: string) {
102
+ super(message)
103
+ this.name = 'PrivilegeDeniedError'
104
+ }
105
+ }
106
+
67
107
  export interface PrivilegedHelperHandle {
68
108
  client: PrivilegedClient
69
109
  close: () => Promise<void>
@@ -92,9 +132,11 @@ export async function startPrivilegedHelper(options: LaunchOptions): Promise<Pri
92
132
  } = options
93
133
 
94
134
  if (!skipAuth && !direct) {
95
- const auth = authenticateSudo(sudoCommand)
96
- if (!auth.ok)
97
- throw new Error(auth.message)
135
+ const auth = authenticateSudo(sudoCommand, options.reason)
136
+ if (!auth.ok) {
137
+ const message = auth.message ?? 'administrator permission is required'
138
+ throw auth.denied ? new PrivilegeDeniedError(message) : new Error(message)
139
+ }
98
140
  }
99
141
 
100
142
  const forwarded = collectForwardedEnv(env)
@@ -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
  })