unplugin-https-reverse-proxy 4.1.0-beta.2 → 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.
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
+ }
@@ -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)
@@ -24,6 +24,8 @@ export interface SessionScope {
24
24
  caddyBin: string
25
25
  /** Caddyfile the helper may load. */
26
26
  caddyConfigPath: string
27
+ /** Shown before the password prompt so the request is not a surprise. */
28
+ reason?: { target: string, https: boolean }
27
29
  log?: (message: string) => void
28
30
  }
29
31
 
@@ -81,6 +83,7 @@ export async function openHelperSession(scope: SessionScope): Promise<OpenedSess
81
83
  caddyStoragePath: scope.caddyStoragePath,
82
84
  caddyBin: scope.caddyBin,
83
85
  caddyConfigPath: scope.caddyConfigPath,
86
+ reason: scope.reason,
84
87
  log: scope.log,
85
88
  })
86
89