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/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,174 @@
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, rmSync } from 'node:fs'
6
+ import { createServer } from 'node:net'
7
+ import process from 'node:process'
8
+ import { attachSocket, createHelperRuntime, createNodeHelperDeps } from './server'
9
+
10
+ export interface HelperBootstrapOptions {
11
+ socketPath: string
12
+ token: string
13
+ hostsPath?: string
14
+ /** Caddy binary; fixed here so the client never names an executable. */
15
+ caddyBin: string
16
+ /** Caddyfile to load; fixed for the same reason. */
17
+ caddyConfigPath: string
18
+ caddyCwd?: string
19
+ caddyEnv?: Record<string, string>
20
+ /**
21
+ * Owner of the socket file. The helper runs as root while its client runs as
22
+ * the developer, so the socket must be handed back or the client cannot
23
+ * connect to it.
24
+ */
25
+ ownerUid?: number
26
+ ownerGid?: number
27
+ /** Caddy storage directory, handed back to the owner when Caddy stops. */
28
+ caddyStoragePath?: string
29
+ deps?: HelperDeps
30
+ log?: (message: string) => void
31
+ }
32
+
33
+ export interface RunningHelper {
34
+ runtime: HelperRuntime
35
+ close: () => Promise<void>
36
+ }
37
+
38
+ const HANDSHAKE_TIMEOUT_MS = 120_000
39
+
40
+ /**
41
+ * Runs the privileged side of the plugin: listens on a local socket, accepts
42
+ * only a client presenting the shared token, then serves until that client
43
+ * disconnects. Losing the client is the cleanup trigger, so a crashed or
44
+ * `SIGKILL`ed plugin still leaves hosts and Caddy restored.
45
+ */
46
+ export async function startHelper(options: HelperBootstrapOptions): Promise<RunningHelper> {
47
+ const { socketPath, token } = options
48
+ const log = options.log ?? ((message: string) => process.stdout.write(`[uhrp-helper] ${message}\n`))
49
+ const deps = options.deps ?? createNodeHelperDeps({
50
+ hostsPath: options.hostsPath,
51
+ log,
52
+ caddyBin: options.caddyBin,
53
+ caddyConfigPath: options.caddyConfigPath,
54
+ caddyCwd: options.caddyCwd,
55
+ caddyEnv: options.caddyEnv,
56
+ caddyStoragePath: options.caddyStoragePath,
57
+ storageOwner: typeof options.ownerUid === 'number'
58
+ ? { uid: options.ownerUid, gid: options.ownerGid ?? options.ownerUid }
59
+ : undefined,
60
+ })
61
+ const runtime = createHelperRuntime(deps)
62
+
63
+ // A previous session that was killed cannot have cleaned up its entries; do
64
+ // it now, before any client attaches.
65
+ await runtime.sweepStaleEntries().catch(() => {})
66
+
67
+ try {
68
+ rmSync(socketPath, { force: true })
69
+ }
70
+ catch {}
71
+
72
+ const connections = new Set<Socket>()
73
+
74
+ const server = createServer((socket) => {
75
+ let buffer = ''
76
+ let authenticated = false
77
+ connections.add(socket)
78
+ socket.on('close', () => connections.delete(socket))
79
+
80
+ const timer = setTimeout(() => {
81
+ if (!authenticated)
82
+ socket.destroy()
83
+ }, HANDSHAKE_TIMEOUT_MS)
84
+ timer.unref?.()
85
+
86
+ const onData = (chunk: Buffer): void => {
87
+ buffer += chunk.toString('utf8')
88
+ const index = buffer.indexOf('\n')
89
+ if (index < 0)
90
+ return
91
+
92
+ const line = buffer.slice(0, index)
93
+ const rest = buffer.slice(index + 1)
94
+
95
+ let parsed: Partial<ClientHandshake>
96
+ try {
97
+ parsed = JSON.parse(line)
98
+ }
99
+ catch {
100
+ socket.destroy()
101
+ return
102
+ }
103
+
104
+ if (parsed.type !== 'handshake' || typeof parsed.token !== 'string') {
105
+ socket.destroy()
106
+ return
107
+ }
108
+
109
+ clearTimeout(timer)
110
+ socket.removeListener('data', onData)
111
+
112
+ if (parsed.token !== token) {
113
+ socket.end(`${JSON.stringify({ type: 'handshake', ok: false, reason: 'invalid token' })}\n`)
114
+ return
115
+ }
116
+
117
+ authenticated = true
118
+ socket.write(`${JSON.stringify({ type: 'handshake', ok: true, pid: process.pid })}\n`)
119
+ attachSocket(socket, runtime, log)
120
+
121
+ // Requests pipelined behind the handshake belong to the attached client.
122
+ if (rest.trim() !== '')
123
+ socket.emit('data', Buffer.from(rest))
124
+ }
125
+
126
+ socket.on('data', onData)
127
+ socket.on('error', () => {})
128
+ })
129
+
130
+ await new Promise<void>((resolve, reject) => {
131
+ server.once('error', reject)
132
+ server.listen(socketPath, () => resolve())
133
+ })
134
+
135
+ try {
136
+ // The client is not root: hand the socket back to the developer and keep it
137
+ // private to them. Without the chown, `connect()` fails with EACCES and the
138
+ // client only sees a silent retry loop.
139
+ if (typeof options.ownerUid === 'number') {
140
+ chownSync(socketPath, options.ownerUid, options.ownerGid ?? options.ownerUid)
141
+ chmodSync(socketPath, 0o600)
142
+ }
143
+ else {
144
+ chmodSync(socketPath, 0o600)
145
+ }
146
+ }
147
+ catch (error) {
148
+ log(`could not set socket ownership: ${(error as Error).message}`)
149
+ }
150
+
151
+ return {
152
+ runtime,
153
+ async close() {
154
+ // Closing the listener alone would wait for clients to hang up first;
155
+ // shutdown has to drop the open connections as well.
156
+ for (const socket of connections)
157
+ socket.destroy()
158
+ connections.clear()
159
+ await new Promise<void>((resolve) => {
160
+ server.close(() => resolve())
161
+ })
162
+ },
163
+ }
164
+ }
165
+
166
+ /** Sockets left behind by a killed helper cannot be reused; remove them first. */
167
+ export function removeStaleSocket(socketPath: string): void {
168
+ if (process.platform === 'win32')
169
+ return
170
+ try {
171
+ rmSync(socketPath, { force: true })
172
+ }
173
+ catch {}
174
+ }
@@ -0,0 +1,287 @@
1
+ import type { Buffer } from 'node:buffer'
2
+ import type { Socket } from 'node:net'
3
+ import type {
4
+ ClientHandshake,
5
+ HelperEvent,
6
+ HelperMessage,
7
+ HelperRequest,
8
+ RpcId,
9
+ ServerHandshake,
10
+ } from './protocol'
11
+ import { connect } from 'node:net'
12
+ import process from 'node:process'
13
+ import { decodeMessage, serializeMessage, splitLines } from './protocol'
14
+
15
+ /**
16
+ * Environment variables the privileged helper needs so Caddy behaves like the
17
+ * user's own process (PKI location, proxy settings) without `sudo -E`.
18
+ */
19
+ export const FORWARDED_ENV_KEYS = [
20
+ 'HOME',
21
+ 'USER',
22
+ 'LOGNAME',
23
+ 'TMPDIR',
24
+ 'HTTP_PROXY',
25
+ 'HTTPS_PROXY',
26
+ 'http_proxy',
27
+ 'https_proxy',
28
+ 'NO_PROXY',
29
+ 'no_proxy',
30
+ ] as const
31
+
32
+ export function collectForwardedEnv(source: NodeJS.ProcessEnv = process.env): Record<string, string> {
33
+ const result: Record<string, string> = {}
34
+ for (const key of FORWARDED_ENV_KEYS) {
35
+ const value = source[key]
36
+ if (typeof value === 'string' && value !== '')
37
+ result[key] = value
38
+ }
39
+ return result
40
+ }
41
+
42
+ export interface HelperResults {
43
+ 'ping': { pid: number }
44
+ 'hosts.add': void
45
+ 'hosts.remove': void
46
+ 'port.free': { killed: number[] }
47
+ 'caddy.start': { pid?: number }
48
+ 'caddy.stop': void
49
+ 'ca.trust': { trusted: boolean, output: string }
50
+ }
51
+
52
+ export class HelperConnectionError extends Error {
53
+ constructor(message: string) {
54
+ super(message)
55
+ this.name = 'HelperConnectionError'
56
+ }
57
+ }
58
+
59
+ export interface PrivilegedClient {
60
+ readonly connected: boolean
61
+ call: <M extends keyof HelperResults>(method: M, ...params: HelperParams<M>) => Promise<HelperResults[M]>
62
+ onEvent: (listener: (event: HelperEvent) => void) => () => void
63
+ close: () => Promise<void>
64
+ }
65
+
66
+ type HelperParams<M extends keyof HelperResults>
67
+ = M extends 'hosts.add' | 'hosts.remove' ? [params: { ip: string, host: string }]
68
+ : M extends 'port.free' ? [params: { port: number }]
69
+ : []
70
+
71
+ interface PendingCall {
72
+ resolve: (value: unknown) => void
73
+ reject: (error: Error) => void
74
+ timer: ReturnType<typeof setTimeout>
75
+ }
76
+
77
+ export interface ConnectOptions {
78
+ socketPath: string
79
+ token: string
80
+ /** Total time to wait for the helper to accept the connection. */
81
+ timeoutMs?: number
82
+ retryIntervalMs?: number
83
+ callTimeoutMs?: number
84
+ }
85
+
86
+ function openSocket(socketPath: string): Promise<Socket> {
87
+ return new Promise<Socket>((resolve, reject) => {
88
+ const socket = connect(socketPath)
89
+
90
+ function onConnect(): void {
91
+ socket.removeListener('error', onError)
92
+ resolve(socket)
93
+ }
94
+
95
+ function onError(error: Error): void {
96
+ socket.removeListener('connect', onConnect)
97
+ reject(error)
98
+ }
99
+
100
+ socket.once('error', onError)
101
+ socket.once('connect', onConnect)
102
+ })
103
+ }
104
+
105
+ /**
106
+ * Connects to an already-running helper and performs the token handshake.
107
+ * Retries because the helper may still be starting while sudo asks for a password.
108
+ */
109
+ export async function connectClient(options: ConnectOptions): Promise<PrivilegedClient> {
110
+ const {
111
+ socketPath,
112
+ token,
113
+ timeoutMs = 120_000,
114
+ retryIntervalMs = 100,
115
+ callTimeoutMs = 30_000,
116
+ } = options
117
+
118
+ const deadline = Date.now() + timeoutMs
119
+ let lastError: Error | undefined
120
+
121
+ while (Date.now() < deadline) {
122
+ try {
123
+ return await handshake(socketPath, token, callTimeoutMs)
124
+ }
125
+ catch (error) {
126
+ lastError = error as Error
127
+
128
+ // A permission or protocol error will not resolve by waiting, and
129
+ // retrying hides the real cause behind a silent hang.
130
+ const code = (lastError as NodeJS.ErrnoException).code
131
+ if (code === 'EACCES' || lastError instanceof HelperConnectionError)
132
+ throw lastError
133
+
134
+ await new Promise(resolve => setTimeout(resolve, retryIntervalMs))
135
+ }
136
+ }
137
+
138
+ throw new HelperConnectionError(
139
+ `privileged helper is not reachable at ${socketPath}: ${lastError?.message ?? 'timed out'}`,
140
+ )
141
+ }
142
+
143
+ function handshake(socketPath: string, token: string, callTimeoutMs: number): Promise<PrivilegedClient> {
144
+ return new Promise<PrivilegedClient>((resolve, reject) => {
145
+ void openSocket(socketPath).then((socket) => {
146
+ let buffer = ''
147
+ let settled = false
148
+
149
+ const fail = (error: Error): void => {
150
+ if (settled)
151
+ return
152
+ settled = true
153
+ socket.destroy()
154
+ reject(error)
155
+ }
156
+
157
+ const onData = (chunk: Buffer): void => {
158
+ if (settled)
159
+ return
160
+
161
+ const split = splitLines(buffer, chunk.toString('utf8'))
162
+ buffer = split.rest
163
+ const line = split.lines[0]
164
+ if (line === undefined)
165
+ return
166
+
167
+ let hello: ServerHandshake
168
+ try {
169
+ hello = JSON.parse(line) as ServerHandshake
170
+ }
171
+ catch (error) {
172
+ return fail(new HelperConnectionError(`invalid handshake reply: ${(error as Error).message}`))
173
+ }
174
+
175
+ if (hello.type !== 'handshake')
176
+ return fail(new HelperConnectionError('helper did not answer the handshake'))
177
+
178
+ if (!hello.ok)
179
+ return fail(new HelperConnectionError(hello.reason ?? 'helper rejected the handshake'))
180
+
181
+ settled = true
182
+ socket.removeListener('data', onData)
183
+ socket.removeListener('error', onError)
184
+ const remaining = split.lines.slice(1)
185
+ resolve(createClient(socket, [split.rest, ...remaining].filter(Boolean).join('\n'), callTimeoutMs))
186
+ }
187
+
188
+ function onError(error: Error): void {
189
+ fail(error)
190
+ }
191
+
192
+ socket.on('data', onData)
193
+ socket.on('error', onError)
194
+
195
+ const greeting: ClientHandshake = { type: 'handshake', token }
196
+ socket.write(serializeMessage(greeting))
197
+ }, reject)
198
+ })
199
+ }
200
+
201
+ function createClient(socket: Socket, initialBuffer: string, callTimeoutMs: number): PrivilegedClient {
202
+ let buffer = initialBuffer
203
+ let connected = true
204
+ let nextId: RpcId = 1
205
+
206
+ const pending = new Map<RpcId, PendingCall>()
207
+ const listeners = new Set<(event: HelperEvent) => void>()
208
+
209
+ socket.on('data', (chunk: Buffer) => {
210
+ const split = splitLines(buffer, chunk.toString('utf8'))
211
+ buffer = split.rest
212
+
213
+ for (const line of split.lines) {
214
+ const decoded = decodeMessage(line)
215
+ if (!decoded.ok)
216
+ continue
217
+
218
+ const message: HelperMessage = decoded.message
219
+ if (!('id' in message)) {
220
+ for (const listener of listeners)
221
+ listener(message)
222
+ continue
223
+ }
224
+
225
+ const entry = pending.get(message.id)
226
+ if (!entry)
227
+ continue
228
+
229
+ pending.delete(message.id)
230
+ clearTimeout(entry.timer)
231
+
232
+ if (message.ok)
233
+ entry.resolve(message.result)
234
+ else
235
+ entry.reject(new Error(message.error.message))
236
+ }
237
+ })
238
+
239
+ const disconnect = (): void => {
240
+ if (!connected)
241
+ return
242
+ connected = false
243
+ for (const [id, entry] of pending) {
244
+ clearTimeout(entry.timer)
245
+ pending.delete(id)
246
+ entry.reject(new HelperConnectionError('privileged helper connection closed'))
247
+ }
248
+ }
249
+
250
+ socket.on('close', disconnect)
251
+ socket.on('error', disconnect)
252
+
253
+ function call(method: keyof HelperResults, params?: unknown): Promise<unknown> {
254
+ if (!connected)
255
+ return Promise.reject(new HelperConnectionError('privileged helper is not connected'))
256
+
257
+ const id = nextId++
258
+ const request = { id, method, params } as unknown as HelperRequest
259
+
260
+ return new Promise<unknown>((resolve, reject) => {
261
+ const timer = setTimeout(() => {
262
+ pending.delete(id)
263
+ reject(new HelperConnectionError(`privileged helper did not answer "${method}" in time`))
264
+ }, callTimeoutMs)
265
+
266
+ pending.set(id, { resolve, reject, timer })
267
+ socket.write(serializeMessage(request))
268
+ })
269
+ }
270
+
271
+ return {
272
+ get connected() {
273
+ return connected
274
+ },
275
+ call: call as PrivilegedClient['call'],
276
+ onEvent(listener) {
277
+ listeners.add(listener)
278
+ return () => {
279
+ listeners.delete(listener)
280
+ }
281
+ },
282
+ async close() {
283
+ disconnect()
284
+ socket.destroy()
285
+ },
286
+ }
287
+ }