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
@@ -0,0 +1,124 @@
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
+ /** Admin API socket; `ca.trust` talks to it instead of the TCP default. */
86
+ caddyAdminSocket?: string
87
+ /** Shown before the password prompt so the request is not a surprise. */
88
+ reason?: { target: string, https: boolean }
89
+ }
90
+
91
+ /** Where Caddy keeps its PKI, per platform. */
92
+ export function caddyStoragePath(): string {
93
+ return process.platform === 'win32'
94
+ ? join(process.env.AppData ?? '', 'Caddy')
95
+ : join(process.env.HOME ?? '', 'Library/Application Support/Caddy')
96
+ }
97
+
98
+ /**
99
+ * Opens (or reuses) the privileged helper for this runtime directory.
100
+ * Only the helper runs as root: the plugin process, its caches and its
101
+ * `node_modules` keep the developer's identity.
102
+ */
103
+ export function openPrivilegedSession(
104
+ runtimeDir: string,
105
+ options: PrivilegedSessionOptions,
106
+ ): Promise<OpenedPrivilegedSession> {
107
+ const env = resolvePrivilegedEnv()
108
+ return openHelperSession({
109
+ runtimeDir,
110
+ helperEntry: env.helperEntry,
111
+ helperArgs: env.helperArgs,
112
+ direct: options.direct,
113
+ // Caddy keeps its PKI under the user's home; the helper runs it as root, so
114
+ // it must know which tree to hand back afterwards.
115
+ caddyStoragePath: caddyStoragePath(),
116
+ // The binary and Caddyfile are fixed here: the privileged helper must never
117
+ // execute a path supplied over the socket.
118
+ caddyBin: options.caddyBin,
119
+ caddyConfigPath: options.caddyConfigPath,
120
+ caddyAdminSocket: options.caddyAdminSocket,
121
+ reason: options.reason,
122
+ log: options.log,
123
+ })
124
+ }
@@ -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%'
package/src/index.ts CHANGED
@@ -7,7 +7,9 @@ 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 { consola, generateQrcode, getIpv4List, isAdmin, once } from './utils'
10
+ import { isPrivilegeDenied, markPrivilegeDenied } from './privilege-state'
11
+ import { PrivilegeDeniedError } from './privileged/launch'
12
+ import { consola, generateQrcode, getIpv4List, once } from './utils'
11
13
 
12
14
  let config: ResolvedConfig
13
15
 
@@ -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
  })
@@ -96,10 +113,6 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
96
113
  if (!enable)
97
114
  return
98
115
 
99
- if (!isAdmin()) {
100
- consola.warn('please run as administrator')
101
- return
102
- }
103
116
  if (!target) {
104
117
  consola.fail('please provide target')
105
118
  return
@@ -153,11 +166,6 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
153
166
  if (!enable)
154
167
  return
155
168
 
156
- if (!isAdmin()) {
157
- consola.warn('please run as administrator')
158
- return
159
- }
160
-
161
169
  if (!target) {
162
170
  consola.fail('please provide target')
163
171
  return
@@ -177,6 +185,9 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
177
185
  }
178
186
  const source = `${devServer.host}:${devServer.port}`
179
187
 
188
+ if (isPrivilegeDenied())
189
+ return
190
+
180
191
  if (caddy)
181
192
  return
182
193
 
@@ -204,9 +215,10 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
204
215
  options.https = false
205
216
  }
206
217
 
207
- caddy = new CaddyInstant()
218
+ const instance = new CaddyInstant()
219
+ caddy = instance
208
220
 
209
- caddy.run(source, target, {
221
+ instance.run(source, target, {
210
222
  base,
211
223
  ...options,
212
224
  }).then(() => {
@@ -216,6 +228,14 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
216
228
  consola.success(` ${c.green('➜')} ${c.bold('run caddy reverse proxy success')}: ${colorUrl(`${options.https ? 'https' : 'http'}://${target}${base}`)}`)
217
229
  })
218
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
+ }
219
239
  throw e
220
240
  })
221
241
  },
@@ -231,11 +251,6 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
231
251
  if (!enable)
232
252
  return
233
253
 
234
- if (!isAdmin()) {
235
- consola.warn('please run as administrator')
236
- return
237
- }
238
-
239
254
  if (!target) {
240
255
  consola.fail('please provide target')
241
256
  return
@@ -257,10 +272,11 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
257
272
 
258
273
  const source = `${devServer.host}:${devServer.port}`
259
274
 
260
- if (caddy)
275
+ if (isPrivilegeDenied())
261
276
  return
262
277
 
263
- caddy = new CaddyInstant()
278
+ if (caddy)
279
+ return
264
280
 
265
281
  let base = '/'
266
282
  const publicPath = compiler.options.output?.publicPath as unknown
@@ -272,7 +288,10 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
272
288
  if (base !== '/' && !base.endsWith('/'))
273
289
  base = `${base}/`
274
290
 
275
- caddy.run(source, target, {
291
+ const instance = new CaddyInstant()
292
+ caddy = instance
293
+
294
+ instance.run(source, target, {
276
295
  base,
277
296
  ...options,
278
297
  }).then(async () => {
@@ -280,6 +299,14 @@ export const unpluginFactory: UnpluginFactory<Options> = options => ({
280
299
 
281
300
  consola.success(` ${c.green('➜')} ${c.bold('run caddy reverse proxy success')}: ${colorUrl(`${options.https ? 'https' : 'http'}://${target}${base}`)}`)
282
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
+ }
283
310
  throw e
284
311
  })
285
312
  },
package/src/nuxt.ts CHANGED
@@ -2,7 +2,7 @@ import type { Options } from './types'
2
2
  import process from 'node:process'
3
3
  import { defineNuxtModule } from '@nuxt/kit'
4
4
  import { vitePrintUrls } from '.'
5
- import { consola, isAdmin } from './utils'
5
+ import { consola } from './utils'
6
6
  import '@nuxt/schema'
7
7
 
8
8
  export interface ModuleOptions extends Options {
@@ -26,11 +26,6 @@ export default defineNuxtModule<ModuleOptions>({
26
26
  if (!enable)
27
27
  return
28
28
 
29
- if (!isAdmin()) {
30
- consola.warn('please run as administrator')
31
- return
32
- }
33
-
34
29
  if (!target) {
35
30
  consola.fail('please provide target')
36
31
  return
@@ -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
+ }
@@ -0,0 +1,289 @@
1
+ import type { Socket } from 'node:net'
2
+ import type { ClientHandshake } from './protocol'
3
+ import type { HelperDeps, HelperRuntime } from './server'
4
+ import { Buffer } from 'node:buffer'
5
+ import { chmodSync, chownSync, existsSync, readFileSync, rmSync } from 'node:fs'
6
+ import { createServer } from 'node:net'
7
+ import { dirname, join } from 'node:path'
8
+ import process from 'node:process'
9
+ import { checkPrivilegedPath, describeRejection } from './path-guard'
10
+ import { attachSocket, createHelperRuntime, createNodeHelperDeps } from './server'
11
+
12
+ export interface HelperBootstrapOptions {
13
+ socketPath: string
14
+ token: string
15
+ hostsPath?: string
16
+ /** Caddy binary; fixed here so the client never names an executable. */
17
+ caddyBin: string
18
+ /** Caddyfile to load; fixed for the same reason. */
19
+ caddyConfigPath: string
20
+ /** Admin API socket; used by `ca.trust` instead of the TCP default. */
21
+ caddyAdminSocket?: string
22
+ caddyCwd?: string
23
+ caddyEnv?: Record<string, string>
24
+ /**
25
+ * Owner of the socket file. The helper runs as root while its client runs as
26
+ * the developer, so the socket must be handed back or the client cannot
27
+ * connect to it.
28
+ */
29
+ ownerUid?: number
30
+ ownerGid?: number
31
+ /** Caddy storage directory, handed back to the owner when Caddy stops. */
32
+ caddyStoragePath?: string
33
+ /**
34
+ * How long the helper lingers with no clients before exiting.
35
+ *
36
+ * Kept short so a helper nobody is using does not stay on the machine as an
37
+ * idle root process. Within the same terminal a restart usually happens
38
+ * sooner than this and still reuses the running helper.
39
+ */
40
+ idleTimeoutMs?: number
41
+ /**
42
+ * Whether the idle timer may end this process.
43
+ *
44
+ * True for the real helper, which owns its process. False when `startHelper`
45
+ * is called in-process (tests): exiting there would take the caller down
46
+ * with it.
47
+ */
48
+ exitOnIdle?: boolean
49
+ deps?: HelperDeps
50
+ log?: (message: string) => void
51
+ }
52
+
53
+ export interface RunningHelper {
54
+ runtime: HelperRuntime
55
+ close: () => Promise<void>
56
+ }
57
+
58
+ const HANDSHAKE_TIMEOUT_MS = 120_000
59
+
60
+ /** Default idle lifetime after the last client leaves. */
61
+ export const DEFAULT_IDLE_TIMEOUT_MS = 30_000
62
+
63
+ /**
64
+ * Runs the privileged side of the plugin: listens on a local socket, accepts
65
+ * only a client presenting the shared token, then serves until that client
66
+ * disconnects. Losing the client is the cleanup trigger, so a crashed or
67
+ * `SIGKILL`ed plugin still leaves hosts and Caddy restored.
68
+ */
69
+ export async function startHelper(options: HelperBootstrapOptions): Promise<RunningHelper> {
70
+ const { socketPath, token } = options
71
+ const log = options.log ?? ((message: string) => process.stdout.write(`[uhrp-helper] ${message}\n`))
72
+
73
+ // The binary and the config travel in as arguments from a process running as
74
+ // the developer. Refuse them when they can be rewritten by someone else:
75
+ // running as root, a swapped binary or Caddyfile is a local privilege gain.
76
+ for (const check of [
77
+ checkPrivilegedPath(options.caddyBin, { label: 'Caddy binary', ownerUid: options.ownerUid }),
78
+ checkPrivilegedPath(options.caddyConfigPath, { label: 'Caddyfile', ownerUid: options.ownerUid }),
79
+ ]) {
80
+ if (check) {
81
+ log(describeRejection(check))
82
+ throw new Error(describeRejection(check))
83
+ }
84
+ }
85
+ const deps = options.deps ?? createNodeHelperDeps({
86
+ hostsPath: options.hostsPath,
87
+ log,
88
+ caddyBin: options.caddyBin,
89
+ caddyConfigPath: options.caddyConfigPath,
90
+ caddyAdminSocket: options.caddyAdminSocket,
91
+ caddyCwd: options.caddyCwd,
92
+ caddyEnv: options.caddyEnv,
93
+ caddyStoragePath: options.caddyStoragePath,
94
+ storageOwner: typeof options.ownerUid === 'number'
95
+ ? { uid: options.ownerUid, gid: options.ownerGid ?? options.ownerUid }
96
+ : undefined,
97
+ })
98
+ const runtime = createHelperRuntime(deps)
99
+
100
+ // A previous session that was killed cannot have cleaned up its entries; do
101
+ // it now, before any client attaches.
102
+ await runtime.sweepStaleEntries().catch(() => {})
103
+
104
+ try {
105
+ rmSync(socketPath, { force: true })
106
+ }
107
+ catch {}
108
+
109
+ const connections = new Set<Socket>()
110
+ const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS
111
+ // The real helper is a process of its own and may end itself. When this runs
112
+ // inside a host process (tests), only the socket is released.
113
+ const exitOnIdle = options.exitOnIdle ?? true
114
+ let idleTimer: ReturnType<typeof setTimeout> | undefined
115
+
116
+ const stopIdleTimer = (): void => {
117
+ if (idleTimer) {
118
+ clearTimeout(idleTimer)
119
+ idleTimer = undefined
120
+ }
121
+ }
122
+
123
+ const server = createServer((socket) => {
124
+ let buffer = ''
125
+ let authenticated = false
126
+ stopIdleTimer()
127
+ connections.add(socket)
128
+ socket.on('close', () => {
129
+ connections.delete(socket)
130
+ if (connections.size === 0)
131
+ scheduleIdleExit()
132
+ })
133
+
134
+ const timer = setTimeout(() => {
135
+ if (!authenticated)
136
+ socket.destroy()
137
+ }, HANDSHAKE_TIMEOUT_MS)
138
+ timer.unref?.()
139
+
140
+ const onData = (chunk: Buffer): void => {
141
+ buffer += chunk.toString('utf8')
142
+ const index = buffer.indexOf('\n')
143
+ if (index < 0)
144
+ return
145
+
146
+ const line = buffer.slice(0, index)
147
+ const rest = buffer.slice(index + 1)
148
+
149
+ let parsed: Partial<ClientHandshake>
150
+ try {
151
+ parsed = JSON.parse(line)
152
+ }
153
+ catch {
154
+ socket.destroy()
155
+ return
156
+ }
157
+
158
+ if (parsed.type !== 'handshake' || typeof parsed.token !== 'string') {
159
+ socket.destroy()
160
+ return
161
+ }
162
+
163
+ clearTimeout(timer)
164
+ socket.removeListener('data', onData)
165
+
166
+ if (parsed.token !== token) {
167
+ socket.end(`${JSON.stringify({ type: 'handshake', ok: false, reason: 'invalid token' })}\n`)
168
+ return
169
+ }
170
+
171
+ authenticated = true
172
+ socket.write(`${JSON.stringify({ type: 'handshake', ok: true, pid: process.pid })}\n`)
173
+ attachSocket(socket, runtime, log)
174
+
175
+ // Requests pipelined behind the handshake belong to the attached client.
176
+ if (rest.trim() !== '')
177
+ socket.emit('data', Buffer.from(rest))
178
+ }
179
+
180
+ socket.on('data', onData)
181
+ socket.on('error', () => {})
182
+ })
183
+
184
+ /**
185
+ * Exits once nobody needs the helper.
186
+ *
187
+ * Without this, every dev server that ever ran leaves a root process behind.
188
+ * The socket and token are removed too, but only while they still carry this
189
+ * process's identity: a newer helper may already own them.
190
+ */
191
+ function scheduleIdleExit(): void {
192
+ if (idleTimer || connections.size > 0)
193
+ return
194
+
195
+ idleTimer = setTimeout(() => {
196
+ idleTimer = undefined
197
+ if (connections.size > 0)
198
+ return
199
+
200
+ log('no clients left; shutting down')
201
+ removeOwnSocket(socketPath, token)
202
+
203
+ if (!exitOnIdle) {
204
+ server.close()
205
+ return
206
+ }
207
+
208
+ server.close(() => process.exit(0))
209
+ // A listener with no clients closes synchronously, but keep a backstop so
210
+ // the process cannot linger if a close callback never arrives.
211
+ setTimeout(() => process.exit(0), 1000).unref?.()
212
+ }, idleTimeoutMs)
213
+ idleTimer.unref?.()
214
+ }
215
+
216
+ await new Promise<void>((resolve, reject) => {
217
+ server.once('error', reject)
218
+ server.listen(socketPath, () => resolve())
219
+ })
220
+
221
+ // A helper nobody ever connects to is as useless as one whose clients left:
222
+ // start the timer now so a failed handshake does not strand it.
223
+ scheduleIdleExit()
224
+
225
+ try {
226
+ // The client is not root: hand the socket back to the developer and keep it
227
+ // private to them. Without the chown, `connect()` fails with EACCES and the
228
+ // client only sees a silent retry loop.
229
+ if (typeof options.ownerUid === 'number') {
230
+ chownSync(socketPath, options.ownerUid, options.ownerGid ?? options.ownerUid)
231
+ chmodSync(socketPath, 0o600)
232
+ }
233
+ else {
234
+ chmodSync(socketPath, 0o600)
235
+ }
236
+ }
237
+ catch (error) {
238
+ log(`could not set socket ownership: ${(error as Error).message}`)
239
+ }
240
+
241
+ return {
242
+ runtime,
243
+ async close() {
244
+ // Closing the listener alone would wait for clients to hang up first;
245
+ // shutdown has to drop the open connections as well.
246
+ for (const socket of connections)
247
+ socket.destroy()
248
+ connections.clear()
249
+ await new Promise<void>((resolve) => {
250
+ server.close(() => resolve())
251
+ })
252
+ },
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Removes this helper's socket and token, but only if they are still its own.
258
+ *
259
+ * A newer helper may have taken both over; deleting them then would break a
260
+ * session this process never served.
261
+ */
262
+ function removeOwnSocket(socketPath: string, token: string): void {
263
+ if (process.platform === 'win32')
264
+ return
265
+
266
+ try {
267
+ const tokensDir = dirname(socketPath)
268
+ const tokenFile = join(tokensDir, 'helper.token')
269
+ const current = existsSync(tokenFile) ? readFileSync(tokenFile, 'utf8').trim() : undefined
270
+ if (current === token)
271
+ rmSync(tokenFile, { force: true })
272
+ }
273
+ catch {}
274
+
275
+ try {
276
+ rmSync(socketPath, { force: true })
277
+ }
278
+ catch {}
279
+ }
280
+
281
+ /** Sockets left behind by a killed helper cannot be reused; remove them first. */
282
+ export function removeStaleSocket(socketPath: string): void {
283
+ if (process.platform === 'win32')
284
+ return
285
+ try {
286
+ rmSync(socketPath, { force: true })
287
+ }
288
+ catch {}
289
+ }