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

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 (53) hide show
  1. package/README.md +30 -6
  2. package/dist/{chunk-2P7SUCQ3.js → chunk-EY3HXBJ4.js} +0 -26
  3. package/dist/{chunk-5R33PUJP.js → chunk-HBXMM25C.js} +765 -229
  4. package/dist/chunk-RKHJN6KY.js +53 -0
  5. package/dist/chunk-WOMR3ZLM.js +17 -0
  6. package/dist/esbuild.cjs +870 -306
  7. package/dist/esbuild.js +4 -2
  8. package/dist/index.cjs +870 -306
  9. package/dist/index.js +4 -2
  10. package/dist/nuxt.cjs +842 -292
  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 +668 -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 +641 -0
  20. package/dist/rollup.cjs +870 -306
  21. package/dist/rollup.js +4 -2
  22. package/dist/rspack.cjs +870 -306
  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 +870 -306
  29. package/dist/vite.js +4 -2
  30. package/dist/webpack.cjs +870 -306
  31. package/dist/webpack.js +4 -2
  32. package/package.json +1 -4
  33. package/src/caddy/caddyfile.ts +1 -0
  34. package/src/caddy/index.ts +184 -110
  35. package/src/caddy/privileged.ts +121 -0
  36. package/src/caddy/utils.ts +0 -43
  37. package/src/index.ts +49 -22
  38. package/src/nuxt.ts +1 -6
  39. package/src/privilege-state.ts +23 -0
  40. package/src/privileged/bootstrap.ts +174 -0
  41. package/src/privileged/client.ts +287 -0
  42. package/src/privileged/entry.ts +73 -0
  43. package/src/privileged/hosts-file.ts +142 -0
  44. package/src/privileged/launch.ts +235 -0
  45. package/src/privileged/ports.ts +45 -0
  46. package/src/privileged/protocol.ts +111 -0
  47. package/src/privileged/server.ts +465 -0
  48. package/src/privileged/session.ts +117 -0
  49. package/src/privileged/validation.ts +38 -0
  50. package/src/privileged/windows-elevation.ts +80 -0
  51. package/src/privileged-helper.ts +109 -0
  52. package/src/utils.ts +0 -28
  53. package/src/host/index.ts +0 -52
package/dist/webpack.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import {
2
2
  unpluginFactory
3
- } from "./chunk-5R33PUJP.js";
3
+ } from "./chunk-HBXMM25C.js";
4
+ import "./chunk-EY3HXBJ4.js";
4
5
  import "./chunk-C5A7XLKL.js";
5
- import "./chunk-2P7SUCQ3.js";
6
+ import "./chunk-WOMR3ZLM.js";
7
+ import "./chunk-RKHJN6KY.js";
6
8
 
7
9
  // src/webpack.ts
8
10
  import { createWebpackPlugin } from "unplugin";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "unplugin-https-reverse-proxy",
3
3
  "type": "module",
4
- "version": "4.0.4",
4
+ "version": "4.1.0-beta.3",
5
5
  "description": "",
6
6
  "license": "MIT",
7
7
  "homepage": "https://github.com/zcf0508/unplugin-https-reverse-proxy#readme",
@@ -119,7 +119,6 @@
119
119
  "console-table-printer": "^2.12.1",
120
120
  "dotenv-flow": "^4.1.0",
121
121
  "got-cjs": "^12.5.4",
122
- "hostile": "^1.4.0",
123
122
  "http-proxy-agent": "^7.0.2",
124
123
  "https-proxy-agent": "^7.0.6",
125
124
  "ora": "5",
@@ -133,8 +132,6 @@
133
132
  "@nuxt/schema": "^3.16.1",
134
133
  "@rsbuild/core": "^0.4.15",
135
134
  "@rspack/core": "^0.5.9",
136
- "@types/hostile": "^1.3.5",
137
- "@types/kill-port": "^2.0.3",
138
135
  "@types/node": "^20.11.19",
139
136
  "@types/proper-lockfile": "^4.1.4",
140
137
  "@types/qrcode-terminal": "^0.12.2",
@@ -26,6 +26,7 @@ export function validateTemplateContext(ctx: unknown): typeof CaddyContext.infer
26
26
  export const caddyTemplate = `{
27
27
  debug
28
28
  auto_https disable_redirects ignore_loaded_certs
29
+ skip_install_trust
29
30
  }
30
31
 
31
32
  :7601 {
@@ -1,4 +1,6 @@
1
- import { spawn } from 'node:child_process'
1
+ import type { Buffer } from 'node:buffer'
2
+ import type { OpenedPrivilegedSession } from './privileged'
3
+ import { execFile } from 'node:child_process'
2
4
  import { chmodSync, createWriteStream, existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs'
3
5
  import { readFile, unlink, writeFile } from 'node:fs/promises'
4
6
  import { platform } from 'node:os'
@@ -9,25 +11,23 @@ import { got } from 'got-cjs'
9
11
  import { HttpProxyAgent } from 'http-proxy-agent'
10
12
  import { HttpsProxyAgent } from 'https-proxy-agent'
11
13
  import * as lockfile from 'proper-lockfile'
12
- import { addHost, removeHost } from '../host'
13
- import { chmodRecursive, consola, once } from '../utils'
14
+ import { consola, once } from '../utils'
14
15
  import { genCaddyfile, validateTemplateContext } from './caddyfile'
15
16
  import { useCaddyConfig } from './config'
16
17
  import { caddyFilePath, caddyLockFilePath, caddyPath, supportList, TEMP_DIR } from './constants'
17
18
  import { isCaddyReloadCancellation } from './log'
19
+ import { HelperCaddyProcess, openPrivilegedSession } from './privileged'
18
20
  import { formatTlsHealthFailure, waitForLocalTls } from './tls-health'
19
- import { kill, logProgress, logProgressOver, tryPort } from './utils'
21
+ import { logProgress, logProgressOver } from './utils'
20
22
 
21
23
  export async function download(): Promise<string> {
22
24
  if (await testCaddy())
23
25
  return caddyPath
24
26
 
25
27
  return new Promise<string>((resolve, reject) => {
26
- // https://caddyserver.com/api/download?os=darwin&arch=amd64
27
-
28
28
  if (!existsSync(TEMP_DIR)) {
29
29
  mkdirSync(TEMP_DIR, { recursive: true })
30
- chmodSync(TEMP_DIR, 0o777)
30
+ chmodSync(TEMP_DIR, 0o700)
31
31
  }
32
32
 
33
33
  const file = createWriteStream(caddyPath)
@@ -55,8 +55,7 @@ export async function download(): Promise<string> {
55
55
  const httpsAgent = httpsProxy ? new HttpsProxyAgent(httpsProxy) : undefined
56
56
 
57
57
  const chmodCaddyOnce = once(() => {
58
- // chmod +x
59
- chmodSync(caddyPath, 0o777)
58
+ chmodSync(caddyPath, 0o755)
60
59
  })
61
60
 
62
61
  got.stream(dowmloadLink, {
@@ -71,30 +70,32 @@ export async function download(): Promise<string> {
71
70
  logProgressOver()
72
71
  if (process.platform === 'win32')
73
72
  return resolve(caddyPath)
74
- // chmod +x
75
- chmodSync(caddyPath, 0o777)
73
+ chmodSync(caddyPath, 0o755)
76
74
  resolve(caddyPath)
77
75
  }).on('error', (err) => {
78
- // consola.error(err)
79
76
  reject(err)
80
77
  })
81
78
  })
82
79
  }
83
80
 
84
- function testCaddy(): Promise<boolean> {
85
- return new Promise((resolve, reject) => {
81
+ /**
82
+ * Checks for a usable binary by running it with no arguments: Caddy prints its
83
+ * usage and exits, so this neither binds a port nor needs privileges.
84
+ */
85
+ export function testCaddy(): Promise<boolean> {
86
+ return new Promise((resolve) => {
86
87
  if (!existsSync(caddyPath))
87
88
  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)
89
+
90
+ try {
91
+ chmodSync(caddyPath, 0o755)
92
+ }
93
+ catch {}
94
+
95
+ execFile(caddyPath, [], { timeout: 10_000 }, (error, stdout) => {
96
+ if (error && !stdout)
97
+ return resolve(false)
98
+ resolve(stdout.length > 0)
98
99
  })
99
100
  })
100
101
  }
@@ -129,32 +130,68 @@ function splitHostPort(addr: string): { host: string, port: string } {
129
130
  return { host: addr.slice(0, lastColon), port: addr.slice(lastColon + 1) }
130
131
  }
131
132
 
133
+ /** Injection point for tests: opens the privileged helper without sudo. */
134
+ export interface CaddyDependencies {
135
+ openSession: (runtimeDir: string, reason?: { target: string, https: boolean }) => Promise<OpenedPrivilegedSession>
136
+ }
137
+
138
+ const defaultDependencies: CaddyDependencies = {
139
+ openSession: (runtimeDir, reason) => openPrivilegedSession(runtimeDir, {
140
+ log: message => consola.debug(message),
141
+ // Both paths are fixed here, before any socket traffic: the privileged
142
+ // helper must never execute something named in a request.
143
+ caddyBin: caddyPath,
144
+ caddyConfigPath: caddyFilePath,
145
+ reason,
146
+ }),
147
+ }
148
+
132
149
  export class CaddyInstant {
133
150
  private inited = false
134
151
  private stoped = false
152
+ private readonly deps: CaddyDependencies
135
153
 
136
154
  /** has locked when start */
137
155
  private locked = false
138
156
  private caddyfile: string | undefined
139
157
 
140
- private _caddyChild: ReturnType<typeof spawn> | undefined
158
+ private _caddyChild: HelperCaddyProcess | undefined
159
+ private _session: OpenedPrivilegedSession | undefined
141
160
 
142
161
  private _source: string | undefined
143
162
  private _target: string | undefined
144
- private _stopEffects: Array< (() => void)> = []
145
-
146
- constructor() {
147
- testCaddy().then(async () => {
148
- this.inited = true
149
- })
163
+ private _stopEffects: Array<(() => void)> = []
150
164
 
165
+ constructor(deps: Partial<CaddyDependencies> = {}) {
166
+ this.deps = { ...defaultDependencies, ...deps }
151
167
  this.locked = this.checkLock()
152
168
  }
153
169
 
154
170
  checkLock(): boolean {
155
171
  if (!existsSync(caddyLockFilePath))
156
172
  writeFileSync(caddyLockFilePath, '')
157
- return lockfile.checkSync(caddyLockFilePath)
173
+ try {
174
+ return lockfile.checkSync(caddyLockFilePath)
175
+ }
176
+ catch {
177
+ return false
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Connects to the privileged helper, starting it once if needed. It is
183
+ * reused across instances so a second dev server does not ask for a
184
+ * password again.
185
+ */
186
+ private async session(reason?: { target: string, https: boolean }): Promise<OpenedPrivilegedSession> {
187
+ // A crashed helper leaves the cached client permanently disconnected; drop
188
+ // it so the next call starts a fresh helper instead of failing forever.
189
+ if (this._session && !this._session.client.connected)
190
+ this._session = undefined
191
+
192
+ if (!this._session)
193
+ this._session = await this.deps.openSession(TEMP_DIR, reason)
194
+ return this._session
158
195
  }
159
196
 
160
197
  async getCaddyfile(): Promise<string> {
@@ -189,6 +226,34 @@ export class CaddyInstant {
189
226
  this.inited = true
190
227
  }
191
228
 
229
+ /**
230
+ * Hands the Caddy storage directory back to the developer. Local CA data
231
+ * created by an earlier root-owned run must stay readable for `tls internal`
232
+ * to keep serving the same certificate the browser already trusts.
233
+ */
234
+ /**
235
+ * Installs Caddy's local root CA so the browser stops warning about
236
+ * `tls internal` certificates. Generating the CA and trusting it are separate
237
+ * steps: a new CA appears whenever the previous one expires or the storage is
238
+ * replaced, so this runs on every start instead of once.
239
+ *
240
+ * The helper owns both the binary path and the command, so nothing here can
241
+ * be turned into an arbitrary root execution.
242
+ */
243
+ private async trustLocalCa(session: OpenedPrivilegedSession): Promise<void> {
244
+ try {
245
+ const result = await session.client.call('ca.trust')
246
+ if (result.trusted)
247
+ consola.debug(`Caddy local CA is trusted\n${result.output}`)
248
+ }
249
+ catch (error) {
250
+ consola.warn(
251
+ `could not install the Caddy root certificate automatically: ${(error as Error).message}\n`
252
+ + `Run \`${caddyPath} trust\` manually if the browser warns about the certificate.`,
253
+ )
254
+ }
255
+ }
256
+
192
257
  private async ensureTlsReady(targetHost: string): Promise<void> {
193
258
  const health = await waitForLocalTls(targetHost)
194
259
  if (!health.healthy)
@@ -251,9 +316,11 @@ export class CaddyInstant {
251
316
 
252
317
  const sourceHost = splitHostPort(source).host
253
318
  const targetHost = splitHostPort(target).host
254
- if (!await addHost(sourceHost, targetHost))
255
- throw new Error('update host failed')
319
+
320
+ const session = await this.session({ target: targetHost, https })
321
+ await session.client.call('hosts.add', { ip: sourceHost, host: targetHost })
256
322
  consola.success('update host success\n')
323
+
257
324
  const normalizeBase = (b: string): string => {
258
325
  let out = b || '/'
259
326
  if (!out.startsWith('/'))
@@ -296,10 +363,10 @@ export class CaddyInstant {
296
363
  writeFileSync(caddyLockFilePath, '')
297
364
 
298
365
  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)
366
+ // Ports 80/443 may still be held by a previous, uncleanly stopped run.
367
+ await session.client.call('port.free', { port: 443 })
368
+ if (process.platform === 'win32' || !https)
369
+ await session.client.call('port.free', { port: 80 })
303
370
  }
304
371
 
305
372
  return new Promise<void>((resolve, reject) => {
@@ -311,8 +378,13 @@ export class CaddyInstant {
311
378
  startupCheckStarted = true
312
379
 
313
380
  try {
314
- if (https)
381
+ if (https) {
382
+ // `caddy trust` reads the CA through the admin API, so it can only
383
+ // run once Caddy is serving. The helper does it as root because the
384
+ // system trust store is not writable by the developer account.
385
+ await this.trustLocalCa(session)
315
386
  await this.ensureTlsReady(targetHost)
387
+ }
316
388
  resolve()
317
389
  }
318
390
  catch (error) {
@@ -344,25 +416,6 @@ export class CaddyInstant {
344
416
 
345
417
  // @ts-expect-error override
346
418
  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
419
  if (this.stoped)
367
420
  return originalExit(code)
368
421
 
@@ -382,56 +435,67 @@ export class CaddyInstant {
382
435
  }
383
436
 
384
437
  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'])
388
-
389
438
  lockfile.lockSync(caddyLockFilePath)
439
+ setupCleanup()
390
440
 
391
- this._caddyChild!.on('error', (err) => {
392
- return reject(err)
393
- })
441
+ void (async () => {
442
+ try {
443
+ const child = new HelperCaddyProcess(session.client)
444
+ this._caddyChild = child
394
445
 
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()
446
+ await session.client.call('caddy.start')
447
+
448
+ child.on('exit', (code: number | null) => {
449
+ if (code !== null && code !== 0)
450
+ reject(new Error(`Caddy exited with code ${code}`))
451
+ })
452
+
453
+ child.stderr.on('data', async (data: Buffer) => {
454
+ const lines = data.toString().split('\n').map((line: string) => line.trim())
455
+ for (const line of lines) {
456
+ // caddy log
457
+ // eslint-disable-next-line no-console
458
+ showCaddyLog && line && console.info(line)
459
+ if (isCaddyReloadCancellation(line)) {
460
+ consola.info('Caddy restarted before TLS issuance completed; retrying.\n')
461
+ continue
411
462
  }
412
- else {
413
- consola.error(`${line}\n`)
414
- // child.kill()
415
- return reject(line)
463
+ try {
464
+ if (line.includes('Error:') || (line && JSON.parse(line).level === 'error')) {
465
+ if (line.includes('parsing caddyfile tokens')) {
466
+ consola.error('Caddyfile syntax error, please contact the author:\n', `${this.caddyfile}\n\n${line}\n`)
467
+ await this.baseCleanup()
468
+ process.exit()
469
+ }
470
+ else {
471
+ consola.error(`${line}\n`)
472
+ return reject(line)
473
+ }
474
+ }
416
475
  }
417
- }
418
- }
419
- catch {}
476
+ catch {}
420
477
 
421
- if (line.includes('config file changed'))
422
- consola.success('Detect added a new reverse proxy config, restarted.\n')
478
+ if (line.includes('config file changed'))
479
+ consola.success('Detect added a new reverse proxy config, restarted.\n')
423
480
 
424
- if (line.includes('caddy proxying') || line.includes('serving initial'))
481
+ if (line.includes('caddy proxying') || line.includes('serving initial'))
482
+ void resolveWhenReady()
483
+ }
484
+ })
485
+
486
+ child.stdout.on('data', (data: Buffer) => {
487
+ consola.info(data.toString())
425
488
  void resolveWhenReady()
489
+ })
426
490
  }
427
- })
428
-
429
- this._caddyChild!.stdout?.on('data', (_data) => {
430
- consola.info(_data.toString())
431
- void resolveWhenReady()
432
- })
433
-
434
- setupCleanup()
491
+ catch (error) {
492
+ // The hosts entry was written before Caddy started; a failed start
493
+ // must not leave it behind while the dev server keeps running.
494
+ if (!this.locked)
495
+ await this.baseCleanup(restore)
496
+ reject(error)
497
+ }
498
+ })()
435
499
  }
436
500
  else {
437
501
  consola.info('Caddy is running in another process.')
@@ -453,26 +517,36 @@ export class CaddyInstant {
453
517
 
454
518
  async baseCleanup(restore: boolean = true): Promise<void> {
455
519
  this._stopEffects.forEach(fn => fn())
520
+ this._stopEffects.length = 0
456
521
 
457
522
  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')
523
+ if (restore && this._source && this._target && this._session) {
524
+ const sourceHost = splitHostPort(this._source).host
525
+ const targetHost = splitHostPort(this._target).host
526
+ await this._session.client.call('hosts.remove', { ip: sourceHost, host: targetHost })
527
+ if (sourceHost === 'localhost')
528
+ await this._session.client.call('hosts.remove', { ip: '127.0.0.1', host: targetHost })
529
+ consola.success('Restore host success.\n')
464
530
  }
465
531
  }
466
532
  catch (e) {
467
- consola.error(e)
468
- consola.fail('Restore host failed.\n')
533
+ // The helper restores its own entries when the connection drops, so a
534
+ // closed session here is not a failed cleanup.
535
+ if (this._session?.client.connected === false) {
536
+ consola.debug('hosts entries are restored by the privileged helper on disconnect')
537
+ }
538
+ else {
539
+ consola.error(e)
540
+ consola.fail('Restore host failed.\n')
541
+ }
469
542
  }
543
+
470
544
  if (!this.locked && !this.stoped) {
471
545
  await this.stopCaddy()
472
546
  try {
473
- await lockfile.unlock(caddyLockFilePath).catch(() => { })
474
- await unlink(caddyFilePath).catch(() => { })
475
- await unlink(caddyLockFilePath).catch(() => { })
547
+ await lockfile.unlock(caddyLockFilePath).catch(() => {})
548
+ await unlink(caddyFilePath).catch(() => {})
549
+ await unlink(caddyLockFilePath).catch(() => {})
476
550
  if (caddyConfig) {
477
551
  await caddyConfig.cleanUp()
478
552
  }
@@ -0,0 +1,121 @@
1
+ import type { PrivilegedClient } from '../privileged/client'
2
+ import type { HelperEvent } from '../privileged/protocol'
3
+ import { Buffer } from 'node:buffer'
4
+ import { EventEmitter } from 'node:events'
5
+ import { dirname, join } from 'node:path'
6
+ import process from 'node:process'
7
+ import { fileURLToPath } from 'node:url'
8
+ import { resolveHelperEntry } from '../privileged/entry'
9
+ import { openHelperSession } from '../privileged/session'
10
+
11
+ export interface PrivilegedEnv {
12
+ /** Executable started as root, resolved from the running bundle. */
13
+ helperEntry: string
14
+ helperArgs: string[]
15
+ }
16
+
17
+ /**
18
+ * Resolves what the helper needs to be started from the current bundle.
19
+ * `__dirname` exists in both the CJS and ESM outputs (tsup injects a shim).
20
+ */
21
+ export function resolvePrivilegedEnv(): PrivilegedEnv {
22
+ const moduleDir = typeof __dirname === 'string'
23
+ ? __dirname
24
+ : dirname(fileURLToPath(import.meta.url))
25
+ const resolved = resolveHelperEntry({ moduleDir })
26
+ return { helperEntry: resolved.entry, helperArgs: resolved.args }
27
+ }
28
+
29
+ /** Re-emits helper events as the `stdout`/`stderr` events the log parsing expects. */
30
+ export class HelperCaddyProcess extends EventEmitter {
31
+ readonly stdout = new EventEmitter()
32
+ readonly stderr = new EventEmitter()
33
+ private finished = false
34
+ private readonly unsubscribe: () => void
35
+
36
+ constructor(private readonly client: PrivilegedClient) {
37
+ super()
38
+ this.unsubscribe = client.onEvent(event => this.handleEvent(event))
39
+ }
40
+
41
+ private handleEvent(event: HelperEvent): void {
42
+ if (event.type !== 'event')
43
+ return
44
+
45
+ if (event.kind === 'caddy.stdout') {
46
+ this.stdout.emit('data', Buffer.from(event.data))
47
+ return
48
+ }
49
+
50
+ if (event.kind === 'caddy.stderr') {
51
+ this.stderr.emit('data', Buffer.from(event.data))
52
+ return
53
+ }
54
+
55
+ if (event.kind === 'caddy.exit') {
56
+ this.finished = true
57
+ this.emit('exit', event.code, event.signal)
58
+ }
59
+ }
60
+
61
+ /** Asks the helper to stop Caddy; the helper also stops it on disconnect. */
62
+ kill(): void {
63
+ this.unsubscribe()
64
+ if (this.finished)
65
+ return
66
+ this.finished = true
67
+ void this.client.call('caddy.stop').catch(() => {})
68
+ }
69
+ }
70
+
71
+ export interface OpenedPrivilegedSession {
72
+ client: PrivilegedClient
73
+ reused: boolean
74
+ close: () => Promise<void>
75
+ }
76
+
77
+ export interface PrivilegedSessionOptions {
78
+ log?: (message: string) => void
79
+ /** Runs the helper as the current user; tests use this to avoid sudo. */
80
+ direct?: boolean
81
+ /** Caddy binary the helper may run; fixed before the session opens. */
82
+ caddyBin: string
83
+ /** Caddyfile the helper may load. */
84
+ caddyConfigPath: string
85
+ /** Shown before the password prompt so the request is not a surprise. */
86
+ reason?: { target: string, https: boolean }
87
+ }
88
+
89
+ /** Where Caddy keeps its PKI, per platform. */
90
+ export function caddyStoragePath(): string {
91
+ return process.platform === 'win32'
92
+ ? join(process.env.AppData ?? '', 'Caddy')
93
+ : join(process.env.HOME ?? '', 'Library/Application Support/Caddy')
94
+ }
95
+
96
+ /**
97
+ * Opens (or reuses) the privileged helper for this runtime directory.
98
+ * Only the helper runs as root: the plugin process, its caches and its
99
+ * `node_modules` keep the developer's identity.
100
+ */
101
+ export function openPrivilegedSession(
102
+ runtimeDir: string,
103
+ options: PrivilegedSessionOptions,
104
+ ): Promise<OpenedPrivilegedSession> {
105
+ const env = resolvePrivilegedEnv()
106
+ return openHelperSession({
107
+ runtimeDir,
108
+ helperEntry: env.helperEntry,
109
+ helperArgs: env.helperArgs,
110
+ direct: options.direct,
111
+ // Caddy keeps its PKI under the user's home; the helper runs it as root, so
112
+ // it must know which tree to hand back afterwards.
113
+ caddyStoragePath: caddyStoragePath(),
114
+ // The binary and Caddyfile are fixed here: the privileged helper must never
115
+ // execute a path supplied over the socket.
116
+ caddyBin: options.caddyBin,
117
+ caddyConfigPath: options.caddyConfigPath,
118
+ reason: options.reason,
119
+ log: options.log,
120
+ })
121
+ }
@@ -1,51 +1,8 @@
1
- import { exec } from 'node:child_process'
2
- import { createServer } from 'node:net'
3
1
  import ora from 'ora'
4
2
  import { consola } from '../utils'
5
3
 
6
4
  let spinner: ora.Ora
7
5
 
8
- /**
9
- * @returns whether the port is in use
10
- */
11
- export function tryPort(port: number): Promise<boolean> {
12
- return new Promise<boolean>((resolve, _reject) => {
13
- try {
14
- const server = createServer().listen(port)
15
- server.on('listening', () => {
16
- server.close()
17
- resolve(false)
18
- })
19
- server.on('error', (err) => {
20
- // @ts-expect-error err.code
21
- if (err.code === 'EADDRINUSE')
22
- resolve(true)
23
- server.close()
24
- })
25
- }
26
- catch {
27
- resolve(true)
28
- }
29
- })
30
- }
31
-
32
- /**
33
- * npx free-port :port
34
- */
35
- export async function kill(port: number): Promise<void> {
36
- return new Promise<void>((resolve, reject) => {
37
- exec(`npx @maxbo/free-port ${port} -s`, (err, stdout, _stderr) => {
38
- if (err) {
39
- reject(err)
40
- }
41
- else {
42
- consola.log(stdout)
43
- resolve()
44
- }
45
- })
46
- })
47
- }
48
-
49
6
  function getNumber(number: number): string {
50
7
  if (number <= 0)
51
8
  return '0%'