unplugin-https-reverse-proxy 4.0.4 → 4.1.0-beta.10

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.
Files changed (56) hide show
  1. package/README.md +30 -6
  2. package/dist/{chunk-2P7SUCQ3.js → chunk-EY3HXBJ4.js} +0 -26
  3. package/dist/chunk-RKHJN6KY.js +53 -0
  4. package/dist/{chunk-5R33PUJP.js → chunk-U7KAIPZC.js} +981 -796
  5. package/dist/chunk-WOMR3ZLM.js +17 -0
  6. package/dist/esbuild.cjs +1077 -864
  7. package/dist/esbuild.js +4 -2
  8. package/dist/index.cjs +1077 -864
  9. package/dist/index.js +4 -2
  10. package/dist/nuxt.cjs +1049 -850
  11. package/dist/nuxt.js +6 -9
  12. package/dist/privilege-state.cjs +44 -0
  13. package/dist/privilege-state.d.cts +7 -0
  14. package/dist/privilege-state.d.ts +7 -0
  15. package/dist/privilege-state.js +10 -0
  16. package/dist/privileged-helper.cjs +824 -0
  17. package/dist/privileged-helper.d.cts +2 -0
  18. package/dist/privileged-helper.d.ts +2 -0
  19. package/dist/privileged-helper.js +797 -0
  20. package/dist/rollup.cjs +1077 -864
  21. package/dist/rollup.js +4 -2
  22. package/dist/rspack.cjs +1077 -864
  23. package/dist/rspack.js +4 -2
  24. package/dist/utils.cjs +0 -28
  25. package/dist/utils.d.cts +1 -3
  26. package/dist/utils.d.ts +1 -3
  27. package/dist/utils.js +1 -5
  28. package/dist/vite.cjs +1077 -864
  29. package/dist/vite.js +4 -2
  30. package/dist/webpack.cjs +1077 -864
  31. package/dist/webpack.js +4 -2
  32. package/package.json +1 -4
  33. package/src/caddy/caddyfile.ts +3 -0
  34. package/src/caddy/config.ts +195 -53
  35. package/src/caddy/constants.ts +30 -1
  36. package/src/caddy/index.ts +239 -206
  37. package/src/caddy/privileged.ts +124 -0
  38. package/src/caddy/utils.ts +0 -43
  39. package/src/index.ts +49 -22
  40. package/src/nuxt.ts +1 -6
  41. package/src/privilege-state.ts +23 -0
  42. package/src/privileged/bootstrap.ts +289 -0
  43. package/src/privileged/client.ts +288 -0
  44. package/src/privileged/entry.ts +73 -0
  45. package/src/privileged/hosts-file.ts +142 -0
  46. package/src/privileged/launch.ts +332 -0
  47. package/src/privileged/path-guard.ts +74 -0
  48. package/src/privileged/ports.ts +45 -0
  49. package/src/privileged/protocol.ts +111 -0
  50. package/src/privileged/server.ts +578 -0
  51. package/src/privileged/session.ts +169 -0
  52. package/src/privileged/validation.ts +38 -0
  53. package/src/privileged/windows-elevation.ts +80 -0
  54. package/src/privileged-helper.ts +123 -0
  55. package/src/utils.ts +0 -28
  56. package/src/host/index.ts +0 -52
@@ -1,33 +1,31 @@
1
- import { spawn } from 'node:child_process'
2
- import { chmodSync, createWriteStream, existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs'
3
- import { readFile, unlink, writeFile } from 'node:fs/promises'
1
+ import type { Buffer } from 'node:buffer'
2
+ import type { OpenedPrivilegedSession } from './privileged'
3
+ import { execFile } from 'node:child_process'
4
+ import { chmodSync, createWriteStream, existsSync, mkdirSync, unlinkSync } from 'node:fs'
5
+ import { readFile, rename, unlink, writeFile } from 'node:fs/promises'
4
6
  import { platform } from 'node:os'
5
7
  import { join } from 'node:path'
6
8
  import process from 'node:process'
7
- import { effect } from 'alien-signals'
8
9
  import { got } from 'got-cjs'
9
10
  import { HttpProxyAgent } from 'http-proxy-agent'
10
11
  import { HttpsProxyAgent } from 'https-proxy-agent'
11
- import * as lockfile from 'proper-lockfile'
12
- import { addHost, removeHost } from '../host'
13
- import { chmodRecursive, consola, once } from '../utils'
12
+ import { consola, once } from '../utils'
14
13
  import { genCaddyfile, validateTemplateContext } from './caddyfile'
15
14
  import { useCaddyConfig } from './config'
16
- import { caddyFilePath, caddyLockFilePath, caddyPath, supportList, TEMP_DIR } from './constants'
15
+ import { caddyAdminSocketPath, caddyFilePath, caddyPath, supportList, TEMP_DIR } from './constants'
17
16
  import { isCaddyReloadCancellation } from './log'
17
+ import { HelperCaddyProcess, openPrivilegedSession } from './privileged'
18
18
  import { formatTlsHealthFailure, waitForLocalTls } from './tls-health'
19
- import { kill, logProgress, logProgressOver, tryPort } from './utils'
19
+ import { logProgress, logProgressOver } from './utils'
20
20
 
21
21
  export async function download(): Promise<string> {
22
22
  if (await testCaddy())
23
23
  return caddyPath
24
24
 
25
25
  return new Promise<string>((resolve, reject) => {
26
- // https://caddyserver.com/api/download?os=darwin&arch=amd64
27
-
28
26
  if (!existsSync(TEMP_DIR)) {
29
27
  mkdirSync(TEMP_DIR, { recursive: true })
30
- chmodSync(TEMP_DIR, 0o777)
28
+ chmodSync(TEMP_DIR, 0o700)
31
29
  }
32
30
 
33
31
  const file = createWriteStream(caddyPath)
@@ -55,8 +53,7 @@ export async function download(): Promise<string> {
55
53
  const httpsAgent = httpsProxy ? new HttpsProxyAgent(httpsProxy) : undefined
56
54
 
57
55
  const chmodCaddyOnce = once(() => {
58
- // chmod +x
59
- chmodSync(caddyPath, 0o777)
56
+ chmodSync(caddyPath, 0o755)
60
57
  })
61
58
 
62
59
  got.stream(dowmloadLink, {
@@ -71,30 +68,32 @@ export async function download(): Promise<string> {
71
68
  logProgressOver()
72
69
  if (process.platform === 'win32')
73
70
  return resolve(caddyPath)
74
- // chmod +x
75
- chmodSync(caddyPath, 0o777)
71
+ chmodSync(caddyPath, 0o755)
76
72
  resolve(caddyPath)
77
73
  }).on('error', (err) => {
78
- // consola.error(err)
79
74
  reject(err)
80
75
  })
81
76
  })
82
77
  }
83
78
 
84
- function testCaddy(): Promise<boolean> {
85
- return new Promise((resolve, reject) => {
79
+ /**
80
+ * Checks for a usable binary by running it with no arguments: Caddy prints its
81
+ * usage and exits, so this neither binds a port nor needs privileges.
82
+ */
83
+ export function testCaddy(): Promise<boolean> {
84
+ return new Promise((resolve) => {
86
85
  if (!existsSync(caddyPath))
87
86
  return resolve(false)
88
- chmodSync(caddyPath, 0o777)
89
- const child = process.platform === 'win32' ? spawn(caddyPath, []) : spawn('sudo', ['-E', caddyPath])
90
- child.on('close', () => {
91
- return resolve(false)
92
- })
93
- child.on('error', (err) => {
94
- return reject(err)
95
- })
96
- child.stdout.on('data', (_data) => {
97
- return resolve(true)
87
+
88
+ try {
89
+ chmodSync(caddyPath, 0o755)
90
+ }
91
+ catch {}
92
+
93
+ execFile(caddyPath, [], { timeout: 10_000 }, (error, stdout) => {
94
+ if (error && !stdout)
95
+ return resolve(false)
96
+ resolve(stdout.length > 0)
98
97
  })
99
98
  })
100
99
  }
@@ -107,11 +106,44 @@ interface RunOptions {
107
106
  healthCheck?: boolean
108
107
  }
109
108
 
109
+ /** Where Caddy keeps the local CA; the template serves it for device installs. */
110
+ function caddyRootPath(): string {
111
+ return join(
112
+ process.platform === 'win32'
113
+ ? `${process.env.AppData!}/Caddy`
114
+ : `${process.env.HOME!}/Library/Application Support/Caddy`,
115
+ './pki/authorities/local',
116
+ )
117
+ }
118
+
119
+ /**
120
+ * Renders the Caddyfile from the routes currently on disk and writes it.
121
+ *
122
+ * Called from inside the config lock, so two projects writing at once cannot
123
+ * render from their own stale view of the routes.
124
+ */
125
+ async function renderCaddyfile(proxies: Record<string, unknown>): Promise<void> {
126
+ const ctx = validateTemplateContext({
127
+ caddy_root: caddyRootPath(),
128
+ admin_socket: caddyAdminSocketPath,
129
+ proxies: Object.values(proxies) as never,
130
+ })
131
+ const content = await genCaddyfile(ctx)
132
+
133
+ // Written through a temporary file: Caddy watches this path, and a reload
134
+ // triggered by a half-written file fails to parse. The starter's stderr
135
+ // handler exits the dev server on a parse error, so a partial read would take
136
+ // down an unrelated project.
137
+ const temp = `${caddyFilePath}.${process.pid}.tmp`
138
+ await writeFile(temp, content)
139
+ await rename(temp, caddyFilePath)
140
+ }
141
+
110
142
  let caddyConfig: ReturnType<typeof useCaddyConfig> | undefined
111
143
 
112
144
  function getCaddyConfig(): ReturnType<typeof useCaddyConfig> {
113
145
  if (!caddyConfig) {
114
- caddyConfig = useCaddyConfig()
146
+ caddyConfig = useCaddyConfig({ renderCaddyfile: renderCaddyfile as never })
115
147
  }
116
148
  return caddyConfig
117
149
  }
@@ -129,32 +161,60 @@ function splitHostPort(addr: string): { host: string, port: string } {
129
161
  return { host: addr.slice(0, lastColon), port: addr.slice(lastColon + 1) }
130
162
  }
131
163
 
164
+ /** Injection point for tests: opens the privileged helper without sudo. */
165
+ export interface CaddyDependencies {
166
+ openSession: (runtimeDir: string, reason?: { target: string, https: boolean }) => Promise<OpenedPrivilegedSession>
167
+ /** Shared route config; tests inject an in-memory one. */
168
+ config: () => ReturnType<typeof useCaddyConfig>
169
+ }
170
+
171
+ const defaultDependencies: CaddyDependencies = {
172
+ openSession: (runtimeDir, reason) => openPrivilegedSession(runtimeDir, {
173
+ log: message => consola.debug(message),
174
+ // Both paths are fixed here, before any socket traffic: the privileged
175
+ // helper must never execute something named in a request.
176
+ caddyBin: caddyPath,
177
+ caddyConfigPath: caddyFilePath,
178
+ caddyAdminSocket: caddyAdminSocketPath,
179
+ reason,
180
+ }),
181
+ config: () => getCaddyConfig(),
182
+ }
183
+
132
184
  export class CaddyInstant {
133
185
  private inited = false
134
186
  private stoped = false
187
+ private readonly deps: CaddyDependencies
135
188
 
136
- /** has locked when start */
137
- private locked = false
138
189
  private caddyfile: string | undefined
139
190
 
140
- private _caddyChild: ReturnType<typeof spawn> | undefined
191
+ private _caddyChild: HelperCaddyProcess | undefined
192
+ private _session: OpenedPrivilegedSession | undefined
141
193
 
142
194
  private _source: string | undefined
143
195
  private _target: string | undefined
144
- private _stopEffects: Array< (() => void)> = []
196
+ private _stopEffects: Array<(() => void)> = []
197
+ /** Identifies this instance's routes in the shared config. */
198
+ private readonly proxyKey = `${process.pid}-${Math.random().toString(36).slice(2, 8)}`
145
199
 
146
- constructor() {
147
- testCaddy().then(async () => {
148
- this.inited = true
149
- })
150
-
151
- this.locked = this.checkLock()
200
+ constructor(deps: Partial<CaddyDependencies> = {}) {
201
+ this.deps = { ...defaultDependencies, ...deps }
152
202
  }
153
203
 
154
- checkLock(): boolean {
155
- if (!existsSync(caddyLockFilePath))
156
- writeFileSync(caddyLockFilePath, '')
157
- return lockfile.checkSync(caddyLockFilePath)
204
+ /**
205
+ * Connects to the privileged helper, starting it once if needed. It is
206
+ * reused across instances so a second dev server does not ask for a
207
+ * password again.
208
+ */
209
+ private async session(reason?: { target: string, https: boolean }): Promise<OpenedPrivilegedSession> {
210
+ // A crashed helper leaves the cached client permanently disconnected; drop
211
+ // it so the next call starts a fresh helper instead of failing forever.
212
+ if (this._session && !this._session.client.connected)
213
+ this._session = undefined
214
+
215
+ if (!this._session)
216
+ this._session = await this.deps.openSession(TEMP_DIR, reason)
217
+ return this._session
158
218
  }
159
219
 
160
220
  async getCaddyfile(): Promise<string> {
@@ -168,12 +228,7 @@ export class CaddyInstant {
168
228
  }
169
229
 
170
230
  get caddyRoot(): string {
171
- return join(
172
- process.platform === 'win32'
173
- ? `${process.env.AppData!}/Caddy`
174
- : `${process.env.HOME!}/Library/Application Support/Caddy`,
175
- './pki/authorities/local',
176
- )
231
+ return caddyRootPath()
177
232
  }
178
233
 
179
234
  async writeCaddyfile(): Promise<void> {
@@ -189,43 +244,40 @@ export class CaddyInstant {
189
244
  this.inited = true
190
245
  }
191
246
 
247
+ /**
248
+ * Hands the Caddy storage directory back to the developer. Local CA data
249
+ * created by an earlier root-owned run must stay readable for `tls internal`
250
+ * to keep serving the same certificate the browser already trusts.
251
+ */
252
+ /**
253
+ * Installs Caddy's local root CA so the browser stops warning about
254
+ * `tls internal` certificates. Generating the CA and trusting it are separate
255
+ * steps: a new CA appears whenever the previous one expires or the storage is
256
+ * replaced, so this runs on every start instead of once.
257
+ *
258
+ * The helper owns both the binary path and the command, so nothing here can
259
+ * be turned into an arbitrary root execution.
260
+ */
261
+ private async trustLocalCa(session: OpenedPrivilegedSession): Promise<void> {
262
+ try {
263
+ const result = await session.client.call('ca.trust')
264
+ if (result.trusted)
265
+ consola.debug(`Caddy local CA is trusted\n${result.output}`)
266
+ }
267
+ catch (error) {
268
+ consola.warn(
269
+ `could not install the Caddy root certificate automatically: ${(error as Error).message}\n`
270
+ + `Run \`${caddyPath} trust\` manually if the browser warns about the certificate.`,
271
+ )
272
+ }
273
+ }
274
+
192
275
  private async ensureTlsReady(targetHost: string): Promise<void> {
193
276
  const health = await waitForLocalTls(targetHost)
194
277
  if (!health.healthy)
195
278
  throw new Error(formatTlsHealthFailure(targetHost, health))
196
279
  }
197
280
 
198
- private async updateCaddyfile(): Promise<void> {
199
- const { configJsonRef } = getCaddyConfig()
200
- const proxies = Object.values(configJsonRef() || {})
201
- const ctx = validateTemplateContext({
202
- caddy_root: this.caddyRoot,
203
- proxies,
204
- })
205
-
206
- this.caddyfile = await genCaddyfile(ctx)
207
- await this.writeCaddyfile()
208
- }
209
-
210
- private async stopCaddy(): Promise<void> {
211
- try {
212
- await got.post('http://127.0.0.1:2019/stop', {
213
- headers: {
214
- Origin: 'http://localhost:2019',
215
- },
216
- retry: {
217
- limit: 0,
218
- },
219
- timeout: {
220
- request: 2000,
221
- },
222
- })
223
- }
224
- catch {}
225
-
226
- this._caddyChild?.kill()
227
- }
228
-
229
281
  /**
230
282
  * @param source like `127.0.0.1:8080`
231
283
  * @param target like `test.abc.com`. And if you provide a port ,it will be ignore.
@@ -251,9 +303,11 @@ export class CaddyInstant {
251
303
 
252
304
  const sourceHost = splitHostPort(source).host
253
305
  const targetHost = splitHostPort(target).host
254
- if (!await addHost(sourceHost, targetHost))
255
- throw new Error('update host failed')
306
+
307
+ const session = await this.session({ target: targetHost, https })
308
+ await session.client.call('hosts.add', { ip: sourceHost, host: targetHost })
256
309
  consola.success('update host success\n')
310
+
257
311
  const normalizeBase = (b: string): string => {
258
312
  let out = b || '/'
259
313
  if (!out.startsWith('/'))
@@ -275,32 +329,18 @@ export class CaddyInstant {
275
329
  },
276
330
  ]
277
331
 
278
- const { writeCaddyConfig } = getCaddyConfig()
279
- writeCaddyConfig(proxies)
280
-
281
- await this.updateCaddyfile()
332
+ // This also renders the Caddyfile, inside the same lock: rendering here
333
+ // afterwards would read the routes outside the lock and could overwrite a
334
+ // newer Caddyfile written by another project in between.
335
+ const { writeCaddyConfig } = this.deps.config()
336
+ await writeCaddyConfig(proxies, this.proxyKey)
282
337
 
283
- let skipInitialCaddyfileEffect = true
284
- this._stopEffects.push(
285
- effect(() => {
286
- getCaddyConfig().configJsonRef()
287
- if (skipInitialCaddyfileEffect) {
288
- skipInitialCaddyfileEffect = false
289
- return
290
- }
291
- void this.updateCaddyfile()
292
- }),
293
- )
294
-
295
- if (!existsSync(caddyLockFilePath))
296
- writeFileSync(caddyLockFilePath, '')
297
-
298
- if (!this.locked) {
299
- if (await tryPort(443))
300
- await kill(443)
301
- if ((process.platform === 'win32' || !https) && await tryPort(80))
302
- await kill(80)
303
- }
338
+ // Ports 80/443 may still be held by a previous, uncleanly stopped run.
339
+ // The helper skips its own Caddy, so this never kills a proxy another
340
+ // project is already using.
341
+ await session.client.call('port.free', { port: 443 })
342
+ if (process.platform === 'win32' || !https)
343
+ await session.client.call('port.free', { port: 80 })
304
344
 
305
345
  return new Promise<void>((resolve, reject) => {
306
346
  let startupCheckStarted = false
@@ -311,13 +351,17 @@ export class CaddyInstant {
311
351
  startupCheckStarted = true
312
352
 
313
353
  try {
314
- if (https)
354
+ if (https) {
355
+ // `caddy trust` reads the CA through the admin API, so it can only
356
+ // run once Caddy is serving. The helper does it as root because the
357
+ // system trust store is not writable by the developer account.
358
+ await this.trustLocalCa(session)
315
359
  await this.ensureTlsReady(targetHost)
360
+ }
316
361
  resolve()
317
362
  }
318
363
  catch (error) {
319
- if (!this.locked)
320
- await this.baseCleanup(restore)
364
+ await this.baseCleanup(restore)
321
365
  reject(error)
322
366
  }
323
367
  }
@@ -344,25 +388,6 @@ export class CaddyInstant {
344
388
 
345
389
  // @ts-expect-error override
346
390
  process.exit = async (code?: number) => {
347
- const port = Number(splitHostPort(source).port)
348
- if (!Number.isNaN(port) && await tryPort(port))
349
- await kill(port)
350
-
351
- // fix `Error: EPERM: operation not permitted`
352
- const pwd = process.cwd()
353
- const viteCacheDir = `${pwd}/node_modules/.vite`
354
- if (existsSync(viteCacheDir))
355
- await chmodRecursive(viteCacheDir, 0o777).catch(() => { })
356
- const viteTempCacheDir = `${pwd}/node_modules/.vite-temp`
357
- if (existsSync(viteTempCacheDir))
358
- await chmodRecursive(viteTempCacheDir, 0o777).catch(() => { })
359
- const nuxtCacheDir = `${pwd}/.nuxt`
360
- if (existsSync(nuxtCacheDir))
361
- await chmodRecursive(nuxtCacheDir, 0o777).catch(() => { })
362
- const webpackCacheDir = `${pwd}/node_modules/.cache`
363
- if (existsSync(webpackCacheDir))
364
- await chmodRecursive(webpackCacheDir, 0o777).catch(() => { })
365
-
366
391
  if (this.stoped)
367
392
  return originalExit(code)
368
393
 
@@ -381,101 +406,109 @@ export class CaddyInstant {
381
406
  }
382
407
  }
383
408
 
384
- if (!this.locked) {
385
- this._caddyChild = process.platform !== 'win32'
386
- ? spawn('sudo', ['-E', caddyPath, 'run', '--config', caddyFilePath, '--watch'])
387
- : spawn(caddyPath, ['run', '--config', caddyFilePath, '--watch'])
409
+ setupCleanup()
388
410
 
389
- lockfile.lockSync(caddyLockFilePath)
390
-
391
- this._caddyChild!.on('error', (err) => {
392
- return reject(err)
393
- })
411
+ void (async () => {
412
+ try {
413
+ const child = new HelperCaddyProcess(session.client)
414
+ this._caddyChild = child
415
+
416
+ const started = await session.client.call('caddy.start')
417
+
418
+ child.on('exit', (code: number | null) => {
419
+ if (code !== null && code !== 0)
420
+ reject(new Error(`Caddy exited with code ${code}`))
421
+ })
422
+
423
+ // Caddy was already serving: its "serving initial" line was emitted
424
+ // when the first project started it, so waiting for it again would
425
+ // hang this instance forever.
426
+ if (started?.started === false) {
427
+ consola.info('Caddy is already running; added this route to it.')
428
+ void resolveWhenReady()
429
+ return
430
+ }
394
431
 
395
- this._caddyChild!.stderr?.on('data', async (data) => {
396
- const lines = (data.toString() as string).split('\n').map(line => line.trim())
397
- for (const line of lines) {
398
- // caddy log
399
- // eslint-disable-next-line no-console
400
- showCaddyLog && line && console.info(line)
401
- if (isCaddyReloadCancellation(line)) {
402
- consola.info('Caddy restarted before TLS issuance completed; retrying.\n')
403
- continue
404
- }
405
- try {
406
- if (line.includes('Error:') || (line && JSON.parse(line).level === 'error')) {
407
- if (line.includes('parsing caddyfile tokens')) {
408
- consola.error('Caddyfile syntax error, please contact the author:\n', `${this.caddyfile}\n\n${line}\n`)
409
- await this.baseCleanup()
410
- process.exit()
411
- }
412
- else {
413
- consola.error(`${line}\n`)
414
- // child.kill()
415
- return reject(line)
432
+ child.stderr.on('data', async (data: Buffer) => {
433
+ const lines = data.toString().split('\n').map((line: string) => line.trim())
434
+ for (const line of lines) {
435
+ // caddy log
436
+ // eslint-disable-next-line no-console
437
+ showCaddyLog && line && console.info(line)
438
+ if (isCaddyReloadCancellation(line)) {
439
+ consola.info('Caddy restarted before TLS issuance completed; retrying.\n')
440
+ continue
441
+ }
442
+ try {
443
+ if (line.includes('Error:') || (line && JSON.parse(line).level === 'error')) {
444
+ if (line.includes('parsing caddyfile tokens')) {
445
+ consola.error('Caddyfile syntax error, please contact the author:\n', `${this.caddyfile}\n\n${line}\n`)
446
+ await this.baseCleanup()
447
+ process.exit()
448
+ }
449
+ else {
450
+ consola.error(`${line}\n`)
451
+ return reject(line)
452
+ }
416
453
  }
417
454
  }
418
- }
419
- catch {}
420
-
421
- if (line.includes('config file changed'))
422
- consola.success('Detect added a new reverse proxy config, restarted.\n')
455
+ catch {}
423
456
 
424
- if (line.includes('caddy proxying') || line.includes('serving initial'))
425
- void resolveWhenReady()
426
- }
427
- })
457
+ if (line.includes('config file changed'))
458
+ consola.success('Detect added a new reverse proxy config, restarted.\n')
428
459
 
429
- this._caddyChild!.stdout?.on('data', (_data) => {
430
- consola.info(_data.toString())
431
- void resolveWhenReady()
432
- })
433
-
434
- setupCleanup()
435
- }
436
- else {
437
- consola.info('Caddy is running in another process.')
460
+ if (line.includes('caddy proxying') || line.includes('serving initial'))
461
+ void resolveWhenReady()
462
+ }
463
+ })
438
464
 
439
- void (async () => {
440
- try {
441
- if (https)
442
- await this.ensureTlsReady(targetHost)
443
- setupCleanup()
444
- resolve()
445
- }
446
- catch (error) {
447
- reject(error)
448
- }
449
- })()
450
- }
465
+ child.stdout.on('data', (data: Buffer) => {
466
+ consola.info(data.toString())
467
+ void resolveWhenReady()
468
+ })
469
+ }
470
+ catch (error) {
471
+ // The hosts entry was written before Caddy started; a failed start
472
+ // must not leave it behind while the dev server keeps running.
473
+ await this.baseCleanup(restore)
474
+ reject(error)
475
+ }
476
+ })()
451
477
  })
452
478
  }
453
479
 
454
480
  async baseCleanup(restore: boolean = true): Promise<void> {
455
481
  this._stopEffects.forEach(fn => fn())
482
+ this._stopEffects.length = 0
456
483
 
457
484
  try {
458
- if (restore && this._source && this._target) {
459
- if (await removeHost(splitHostPort(this._source).host, splitHostPort(this._target).host))
460
- consola.success('Restore host success.\n')
461
-
462
- else
463
- consola.fail('Restore host failed.\n')
485
+ if (restore && this._source && this._target && this._session) {
486
+ const sourceHost = splitHostPort(this._source).host
487
+ const targetHost = splitHostPort(this._target).host
488
+ await this._session.client.call('hosts.remove', { ip: sourceHost, host: targetHost })
489
+ if (sourceHost === 'localhost')
490
+ await this._session.client.call('hosts.remove', { ip: '127.0.0.1', host: targetHost })
491
+ consola.success('Restore host success.\n')
464
492
  }
465
493
  }
466
494
  catch (e) {
467
- consola.error(e)
468
- consola.fail('Restore host failed.\n')
495
+ // The helper restores its own entries when the connection drops, so a
496
+ // closed session here is not a failed cleanup.
497
+ if (this._session?.client.connected === false) {
498
+ consola.debug('hosts entries are restored by the privileged helper on disconnect')
499
+ }
500
+ else {
501
+ consola.error(e)
502
+ consola.fail('Restore host failed.\n')
503
+ }
469
504
  }
470
- if (!this.locked && !this.stoped) {
471
- await this.stopCaddy()
505
+
506
+ // Caddy and the shared Caddyfile belong to every project using this
507
+ // helper. Only this project's route is removed here; the helper stops
508
+ // Caddy when its last client disconnects.
509
+ if (!this.stoped) {
472
510
  try {
473
- await lockfile.unlock(caddyLockFilePath).catch(() => { })
474
- await unlink(caddyFilePath).catch(() => { })
475
- await unlink(caddyLockFilePath).catch(() => { })
476
- if (caddyConfig) {
477
- await caddyConfig.cleanUp()
478
- }
511
+ await this.deps.config().removeProxy(this.proxyKey)
479
512
  }
480
513
  catch (e) {
481
514
  consola.error(e)