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

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 (47) hide show
  1. package/README.md +30 -6
  2. package/dist/{chunk-5R33PUJP.js → chunk-7CA3EIIX.js} +681 -223
  3. package/dist/{chunk-2P7SUCQ3.js → chunk-EY3HXBJ4.js} +0 -26
  4. package/dist/chunk-RKHJN6KY.js +53 -0
  5. package/dist/esbuild.cjs +781 -300
  6. package/dist/esbuild.js +3 -2
  7. package/dist/index.cjs +781 -300
  8. package/dist/index.js +3 -2
  9. package/dist/nuxt.cjs +779 -290
  10. package/dist/nuxt.js +5 -9
  11. package/dist/privileged-helper.cjs +668 -0
  12. package/dist/privileged-helper.d.cts +2 -0
  13. package/dist/privileged-helper.d.ts +2 -0
  14. package/dist/privileged-helper.js +641 -0
  15. package/dist/rollup.cjs +781 -300
  16. package/dist/rollup.js +3 -2
  17. package/dist/rspack.cjs +781 -300
  18. package/dist/rspack.js +3 -2
  19. package/dist/utils.cjs +0 -28
  20. package/dist/utils.d.cts +1 -3
  21. package/dist/utils.d.ts +1 -3
  22. package/dist/utils.js +1 -5
  23. package/dist/vite.cjs +781 -300
  24. package/dist/vite.js +3 -2
  25. package/dist/webpack.cjs +781 -300
  26. package/dist/webpack.js +3 -2
  27. package/package.json +1 -4
  28. package/src/caddy/caddyfile.ts +1 -0
  29. package/src/caddy/index.ts +183 -110
  30. package/src/caddy/privileged.ts +118 -0
  31. package/src/caddy/utils.ts +0 -43
  32. package/src/index.ts +1 -15
  33. package/src/nuxt.ts +1 -6
  34. package/src/privileged/bootstrap.ts +174 -0
  35. package/src/privileged/client.ts +287 -0
  36. package/src/privileged/entry.ts +45 -0
  37. package/src/privileged/hosts-file.ts +142 -0
  38. package/src/privileged/launch.ts +193 -0
  39. package/src/privileged/ports.ts +45 -0
  40. package/src/privileged/protocol.ts +111 -0
  41. package/src/privileged/server.ts +465 -0
  42. package/src/privileged/session.ts +114 -0
  43. package/src/privileged/validation.ts +38 -0
  44. package/src/privileged/windows-elevation.ts +80 -0
  45. package/src/privileged-helper.ts +109 -0
  46. package/src/utils.ts +0 -28
  47. package/src/host/index.ts +0 -52
package/dist/webpack.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import {
2
2
  unpluginFactory
3
- } from "./chunk-5R33PUJP.js";
3
+ } from "./chunk-7CA3EIIX.js";
4
+ import "./chunk-EY3HXBJ4.js";
4
5
  import "./chunk-C5A7XLKL.js";
5
- import "./chunk-2P7SUCQ3.js";
6
+ import "./chunk-RKHJN6KY.js";
6
7
 
7
8
  // src/webpack.ts
8
9
  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.2",
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,67 @@ 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) => Promise<OpenedPrivilegedSession>
136
+ }
137
+
138
+ const defaultDependencies: CaddyDependencies = {
139
+ openSession: runtimeDir => 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
+ }),
146
+ }
147
+
132
148
  export class CaddyInstant {
133
149
  private inited = false
134
150
  private stoped = false
151
+ private readonly deps: CaddyDependencies
135
152
 
136
153
  /** has locked when start */
137
154
  private locked = false
138
155
  private caddyfile: string | undefined
139
156
 
140
- private _caddyChild: ReturnType<typeof spawn> | undefined
157
+ private _caddyChild: HelperCaddyProcess | undefined
158
+ private _session: OpenedPrivilegedSession | undefined
141
159
 
142
160
  private _source: string | undefined
143
161
  private _target: string | undefined
144
- private _stopEffects: Array< (() => void)> = []
145
-
146
- constructor() {
147
- testCaddy().then(async () => {
148
- this.inited = true
149
- })
162
+ private _stopEffects: Array<(() => void)> = []
150
163
 
164
+ constructor(deps: Partial<CaddyDependencies> = {}) {
165
+ this.deps = { ...defaultDependencies, ...deps }
151
166
  this.locked = this.checkLock()
152
167
  }
153
168
 
154
169
  checkLock(): boolean {
155
170
  if (!existsSync(caddyLockFilePath))
156
171
  writeFileSync(caddyLockFilePath, '')
157
- return lockfile.checkSync(caddyLockFilePath)
172
+ try {
173
+ return lockfile.checkSync(caddyLockFilePath)
174
+ }
175
+ catch {
176
+ return false
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Connects to the privileged helper, starting it once if needed. It is
182
+ * reused across instances so a second dev server does not ask for a
183
+ * password again.
184
+ */
185
+ private async session(): Promise<OpenedPrivilegedSession> {
186
+ // A crashed helper leaves the cached client permanently disconnected; drop
187
+ // it so the next call starts a fresh helper instead of failing forever.
188
+ if (this._session && !this._session.client.connected)
189
+ this._session = undefined
190
+
191
+ if (!this._session)
192
+ this._session = await this.deps.openSession(TEMP_DIR)
193
+ return this._session
158
194
  }
159
195
 
160
196
  async getCaddyfile(): Promise<string> {
@@ -189,6 +225,34 @@ export class CaddyInstant {
189
225
  this.inited = true
190
226
  }
191
227
 
228
+ /**
229
+ * Hands the Caddy storage directory back to the developer. Local CA data
230
+ * created by an earlier root-owned run must stay readable for `tls internal`
231
+ * to keep serving the same certificate the browser already trusts.
232
+ */
233
+ /**
234
+ * Installs Caddy's local root CA so the browser stops warning about
235
+ * `tls internal` certificates. Generating the CA and trusting it are separate
236
+ * steps: a new CA appears whenever the previous one expires or the storage is
237
+ * replaced, so this runs on every start instead of once.
238
+ *
239
+ * The helper owns both the binary path and the command, so nothing here can
240
+ * be turned into an arbitrary root execution.
241
+ */
242
+ private async trustLocalCa(session: OpenedPrivilegedSession): Promise<void> {
243
+ try {
244
+ const result = await session.client.call('ca.trust')
245
+ if (result.trusted)
246
+ consola.debug(`Caddy local CA is trusted\n${result.output}`)
247
+ }
248
+ catch (error) {
249
+ consola.warn(
250
+ `could not install the Caddy root certificate automatically: ${(error as Error).message}\n`
251
+ + `Run \`${caddyPath} trust\` manually if the browser warns about the certificate.`,
252
+ )
253
+ }
254
+ }
255
+
192
256
  private async ensureTlsReady(targetHost: string): Promise<void> {
193
257
  const health = await waitForLocalTls(targetHost)
194
258
  if (!health.healthy)
@@ -249,11 +313,13 @@ export class CaddyInstant {
249
313
  if (!this.inited)
250
314
  await this.init()
251
315
 
316
+ const session = await this.session()
317
+
252
318
  const sourceHost = splitHostPort(source).host
253
319
  const targetHost = splitHostPort(target).host
254
- if (!await addHost(sourceHost, targetHost))
255
- throw new Error('update host failed')
320
+ await session.client.call('hosts.add', { ip: sourceHost, host: targetHost })
256
321
  consola.success('update host success\n')
322
+
257
323
  const normalizeBase = (b: string): string => {
258
324
  let out = b || '/'
259
325
  if (!out.startsWith('/'))
@@ -296,10 +362,10 @@ export class CaddyInstant {
296
362
  writeFileSync(caddyLockFilePath, '')
297
363
 
298
364
  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)
365
+ // Ports 80/443 may still be held by a previous, uncleanly stopped run.
366
+ await session.client.call('port.free', { port: 443 })
367
+ if (process.platform === 'win32' || !https)
368
+ await session.client.call('port.free', { port: 80 })
303
369
  }
304
370
 
305
371
  return new Promise<void>((resolve, reject) => {
@@ -311,8 +377,13 @@ export class CaddyInstant {
311
377
  startupCheckStarted = true
312
378
 
313
379
  try {
314
- if (https)
380
+ if (https) {
381
+ // `caddy trust` reads the CA through the admin API, so it can only
382
+ // run once Caddy is serving. The helper does it as root because the
383
+ // system trust store is not writable by the developer account.
384
+ await this.trustLocalCa(session)
315
385
  await this.ensureTlsReady(targetHost)
386
+ }
316
387
  resolve()
317
388
  }
318
389
  catch (error) {
@@ -344,25 +415,6 @@ export class CaddyInstant {
344
415
 
345
416
  // @ts-expect-error override
346
417
  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
418
  if (this.stoped)
367
419
  return originalExit(code)
368
420
 
@@ -382,56 +434,67 @@ export class CaddyInstant {
382
434
  }
383
435
 
384
436
  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
437
  lockfile.lockSync(caddyLockFilePath)
438
+ setupCleanup()
390
439
 
391
- this._caddyChild!.on('error', (err) => {
392
- return reject(err)
393
- })
440
+ void (async () => {
441
+ try {
442
+ const child = new HelperCaddyProcess(session.client)
443
+ this._caddyChild = child
394
444
 
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()
445
+ await session.client.call('caddy.start')
446
+
447
+ child.on('exit', (code: number | null) => {
448
+ if (code !== null && code !== 0)
449
+ reject(new Error(`Caddy exited with code ${code}`))
450
+ })
451
+
452
+ child.stderr.on('data', async (data: Buffer) => {
453
+ const lines = data.toString().split('\n').map((line: string) => line.trim())
454
+ for (const line of lines) {
455
+ // caddy log
456
+ // eslint-disable-next-line no-console
457
+ showCaddyLog && line && console.info(line)
458
+ if (isCaddyReloadCancellation(line)) {
459
+ consola.info('Caddy restarted before TLS issuance completed; retrying.\n')
460
+ continue
411
461
  }
412
- else {
413
- consola.error(`${line}\n`)
414
- // child.kill()
415
- return reject(line)
462
+ try {
463
+ if (line.includes('Error:') || (line && JSON.parse(line).level === 'error')) {
464
+ if (line.includes('parsing caddyfile tokens')) {
465
+ consola.error('Caddyfile syntax error, please contact the author:\n', `${this.caddyfile}\n\n${line}\n`)
466
+ await this.baseCleanup()
467
+ process.exit()
468
+ }
469
+ else {
470
+ consola.error(`${line}\n`)
471
+ return reject(line)
472
+ }
473
+ }
416
474
  }
417
- }
418
- }
419
- catch {}
475
+ catch {}
420
476
 
421
- if (line.includes('config file changed'))
422
- consola.success('Detect added a new reverse proxy config, restarted.\n')
477
+ if (line.includes('config file changed'))
478
+ consola.success('Detect added a new reverse proxy config, restarted.\n')
423
479
 
424
- if (line.includes('caddy proxying') || line.includes('serving initial'))
480
+ if (line.includes('caddy proxying') || line.includes('serving initial'))
481
+ void resolveWhenReady()
482
+ }
483
+ })
484
+
485
+ child.stdout.on('data', (data: Buffer) => {
486
+ consola.info(data.toString())
425
487
  void resolveWhenReady()
488
+ })
426
489
  }
427
- })
428
-
429
- this._caddyChild!.stdout?.on('data', (_data) => {
430
- consola.info(_data.toString())
431
- void resolveWhenReady()
432
- })
433
-
434
- setupCleanup()
490
+ catch (error) {
491
+ // The hosts entry was written before Caddy started; a failed start
492
+ // must not leave it behind while the dev server keeps running.
493
+ if (!this.locked)
494
+ await this.baseCleanup(restore)
495
+ reject(error)
496
+ }
497
+ })()
435
498
  }
436
499
  else {
437
500
  consola.info('Caddy is running in another process.')
@@ -453,26 +516,36 @@ export class CaddyInstant {
453
516
 
454
517
  async baseCleanup(restore: boolean = true): Promise<void> {
455
518
  this._stopEffects.forEach(fn => fn())
519
+ this._stopEffects.length = 0
456
520
 
457
521
  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')
522
+ if (restore && this._source && this._target && this._session) {
523
+ const sourceHost = splitHostPort(this._source).host
524
+ const targetHost = splitHostPort(this._target).host
525
+ await this._session.client.call('hosts.remove', { ip: sourceHost, host: targetHost })
526
+ if (sourceHost === 'localhost')
527
+ await this._session.client.call('hosts.remove', { ip: '127.0.0.1', host: targetHost })
528
+ consola.success('Restore host success.\n')
464
529
  }
465
530
  }
466
531
  catch (e) {
467
- consola.error(e)
468
- consola.fail('Restore host failed.\n')
532
+ // The helper restores its own entries when the connection drops, so a
533
+ // closed session here is not a failed cleanup.
534
+ if (this._session?.client.connected === false) {
535
+ consola.debug('hosts entries are restored by the privileged helper on disconnect')
536
+ }
537
+ else {
538
+ consola.error(e)
539
+ consola.fail('Restore host failed.\n')
540
+ }
469
541
  }
542
+
470
543
  if (!this.locked && !this.stoped) {
471
544
  await this.stopCaddy()
472
545
  try {
473
- await lockfile.unlock(caddyLockFilePath).catch(() => { })
474
- await unlink(caddyFilePath).catch(() => { })
475
- await unlink(caddyLockFilePath).catch(() => { })
546
+ await lockfile.unlock(caddyLockFilePath).catch(() => {})
547
+ await unlink(caddyFilePath).catch(() => {})
548
+ await unlink(caddyLockFilePath).catch(() => {})
476
549
  if (caddyConfig) {
477
550
  await caddyConfig.cleanUp()
478
551
  }
@@ -0,0 +1,118 @@
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
+ }
86
+
87
+ /** Where Caddy keeps its PKI, per platform. */
88
+ export function caddyStoragePath(): string {
89
+ return process.platform === 'win32'
90
+ ? join(process.env.AppData ?? '', 'Caddy')
91
+ : join(process.env.HOME ?? '', 'Library/Application Support/Caddy')
92
+ }
93
+
94
+ /**
95
+ * Opens (or reuses) the privileged helper for this runtime directory.
96
+ * Only the helper runs as root: the plugin process, its caches and its
97
+ * `node_modules` keep the developer's identity.
98
+ */
99
+ export function openPrivilegedSession(
100
+ runtimeDir: string,
101
+ options: PrivilegedSessionOptions,
102
+ ): Promise<OpenedPrivilegedSession> {
103
+ const env = resolvePrivilegedEnv()
104
+ return openHelperSession({
105
+ runtimeDir,
106
+ helperEntry: env.helperEntry,
107
+ helperArgs: env.helperArgs,
108
+ direct: options.direct,
109
+ // Caddy keeps its PKI under the user's home; the helper runs it as root, so
110
+ // it must know which tree to hand back afterwards.
111
+ caddyStoragePath: caddyStoragePath(),
112
+ // The binary and Caddyfile are fixed here: the privileged helper must never
113
+ // execute a path supplied over the socket.
114
+ caddyBin: options.caddyBin,
115
+ caddyConfigPath: options.caddyConfigPath,
116
+ log: options.log,
117
+ })
118
+ }
@@ -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%'