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,578 @@
1
+ import type { Buffer } from 'node:buffer'
2
+ import type { Socket } from 'node:net'
3
+ import type { HelperEvent, HelperRequest, HelperResponse } from './protocol'
4
+ import { execFile, spawn } from 'node:child_process'
5
+ import { lchownSync, lstatSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
6
+ import { platform } from 'node:os'
7
+ import { join } from 'node:path'
8
+ import process from 'node:process'
9
+ import { promisify } from 'node:util'
10
+ import { addHost, removeHost, removeMarkedHosts } from './hosts-file'
11
+ import { parseListeningPids, parseWindowsListeningPids } from './ports'
12
+ import { serializeMessage } from './protocol'
13
+ import { isAllowedFreePort, isSafeHost, isSafeIp } from './validation'
14
+
15
+ const execFileAsync = promisify(execFile)
16
+
17
+ /** Runs a command whose non-zero exit is expected, keeping stdout. */
18
+ function execFileQuiet(file: string, args: string[]): Promise<{ stdout: string }> {
19
+ return new Promise((resolve) => {
20
+ execFile(file, args, (error, stdout) => resolve({ stdout: error && !stdout ? '' : stdout }))
21
+ })
22
+ }
23
+
24
+ export const HOSTS_PATH = platform() === 'win32'
25
+ ? 'C:\\Windows\\System32\\drivers\\etc\\hosts'
26
+ : '/etc/hosts'
27
+
28
+ /**
29
+ * Command that drops the resolver cache so a fresh hosts entry is visible
30
+ * immediately. Each platform caches differently, so the command is per-OS.
31
+ */
32
+ const FLUSH_COMMANDS: Record<string, Array<{ file: string, args: string[] }>> = {
33
+ win32: [{ file: 'ipconfig', args: ['/flushdns'] }],
34
+ darwin: [{ file: 'killall', args: ['-HUP', 'mDNSResponder'] }],
35
+ // systemd-resolved is the common case; the legacy name covers older distros
36
+ // and hosts without resolved simply have no cache to drop.
37
+ linux: [
38
+ { file: 'resolvectl', args: ['flush-caches'] },
39
+ { file: 'systemd-resolve', args: ['--flush-caches'] },
40
+ ],
41
+ }
42
+
43
+ const FLUSH_FALLBACK = [{ file: 'killall', args: ['-HUP', 'mDNSResponder'] }] as const
44
+
45
+ /** Handle on the Caddy process owned by the helper. */
46
+ export interface CaddyChild {
47
+ pid?: number
48
+ kill: (signal?: NodeJS.Signals) => boolean
49
+ /** False once the process has exited; undefined when unknown. */
50
+ running?: boolean
51
+ }
52
+
53
+ /**
54
+ * Everything privileged the helper can do.
55
+ *
56
+ * Paths and arguments are fixed at helper start-up; the client only chooses
57
+ * when* something happens, never *what* runs. That keeps the privileged
58
+ * surface to a handful of primitives even if the socket is reached by an
59
+ * unexpected local process.
60
+ */
61
+ export interface HelperDeps {
62
+ readHosts: () => string
63
+ writeHosts: (content: string) => void
64
+ flushDns: () => Promise<void>
65
+ /** Releases a port; never kills the pid in `keepPid`. */
66
+ freePort: (port: number, keepPid?: number) => Promise<number[]>
67
+ /** Caddy binary and the Caddyfile it must load, both fixed at start-up. */
68
+ caddyBin: string
69
+ caddyConfigPath: string
70
+ caddyCwd: string
71
+ caddyEnv: Record<string, string>
72
+ /** Installs the local root CA using a helper-owned command, not a client path. */
73
+ trustLocalCa: () => Promise<{ trusted: boolean, output: string }>
74
+ /** Caddy writes its PKI as root; this tree goes back to the user afterwards. */
75
+ caddyStoragePath?: string
76
+ storageOwner?: { uid: number, gid: number }
77
+ /** Starts Caddy with the configuration fixed at helper start-up. */
78
+ startCaddy: (emit: (event: HelperEvent) => void) => CaddyChild
79
+ stopCaddy: () => Promise<void>
80
+ log: (message: string) => void
81
+ }
82
+
83
+ /**
84
+ * Resolver caching only affects how fast a new entry is picked up, so a failed
85
+ * flush is logged instead of rolling back a hosts edit that already succeeded.
86
+ */
87
+ async function flushDnsBestEffort(deps: HelperDeps): Promise<void> {
88
+ try {
89
+ await deps.flushDns()
90
+ }
91
+ catch (error) {
92
+ deps.log(`dns flush failed: ${(error as Error).message}`)
93
+ }
94
+ }
95
+
96
+ export interface HelperRuntime {
97
+ /** `client` identifies the caller so shared host mappings are reference-counted. */
98
+ handle: (request: HelperRequest, client?: object) => Promise<HelperResponse>
99
+ /** Registers a connected client so Caddy output reaches it. */
100
+ attach: (client: object) => void
101
+ /** Last client gone: stop Caddy and drop the hosts entries this plugin wrote. */
102
+ detach: (client: object) => Promise<void>
103
+ /** Runs the teardown once, regardless of how many clients were attached. */
104
+ dispose: () => Promise<void>
105
+ /** Removes hosts entries a previous crashed session left behind. */
106
+ sweepStaleEntries: () => Promise<boolean>
107
+ /** Number of attached dev servers; a signal must not tear down a live one. */
108
+ clientCount: () => number
109
+ }
110
+
111
+ /**
112
+ * Request router shared by the helper process and its tests. Kept free of
113
+ * sockets so tests can drive it against a temp hosts file and a fake Caddy.
114
+ */
115
+ export function createHelperRuntime(deps: HelperDeps): HelperRuntime {
116
+ const clients = new Set<object>()
117
+ let teardown: Promise<void> | undefined
118
+ // Bumped whenever a client attaches. A teardown that started before the bump
119
+ // belongs to a previous generation and must not touch the new session.
120
+ let generation = 0
121
+ let caddy: CaddyChild | undefined
122
+
123
+ // Which client asked for which host. Two projects may map the same domain, so
124
+ // a mapping is only removed once its last holder is gone.
125
+ const hostOwners = new Map<string, Set<object>>()
126
+
127
+ const hostId = (ip: string, host: string): string => `${ip} ${host.toLowerCase()}`
128
+
129
+ function addHostOwner(client: object | undefined, ip: string, host: string): void {
130
+ const id = hostId(ip, host)
131
+ let owners = hostOwners.get(id)
132
+ if (!owners) {
133
+ owners = new Set()
134
+ hostOwners.set(id, owners)
135
+ }
136
+ owners.add(client ?? deps)
137
+ }
138
+
139
+ /** True when no other client still needs this mapping. */
140
+ function releaseHostOwner(client: object | undefined, ip: string, host: string): boolean {
141
+ const id = hostId(ip, host)
142
+ const owners = hostOwners.get(id)
143
+ if (!owners)
144
+ return true
145
+
146
+ owners.delete(client ?? deps)
147
+ if (owners.size > 0)
148
+ return false
149
+
150
+ hostOwners.delete(id)
151
+ return true
152
+ }
153
+
154
+ const broadcast = (event: HelperEvent): void => {
155
+ const line = serializeMessage(event)
156
+ for (const client of clients) {
157
+ const socket = client as Socket
158
+ if (typeof socket.write === 'function' && !socket.destroyed)
159
+ socket.write(line)
160
+ }
161
+ }
162
+
163
+ async function stopCaddy(): Promise<void> {
164
+ const current = caddy
165
+ caddy = undefined
166
+ if (current) {
167
+ try {
168
+ current.kill('SIGTERM')
169
+ }
170
+ catch {}
171
+ }
172
+ await deps.stopCaddy().catch(() => {})
173
+
174
+ // Give Caddy's files back to the developer: the next start runs as root
175
+ // again, but the developer must be able to read the PKI in between.
176
+ if (deps.caddyStoragePath && deps.storageOwner)
177
+ await reclaimTree(deps.caddyStoragePath, deps.storageOwner.uid, deps.storageOwner.gid).catch(() => {})
178
+ }
179
+
180
+ function dispose(): Promise<void> {
181
+ if (!teardown) {
182
+ const startedAt = generation
183
+ teardown = (async () => {
184
+ try {
185
+ // A client may have attached while this teardown was queued; its
186
+ // entries belong to the new session and must survive.
187
+ if (generation === startedAt) {
188
+ const edited = removeMarkedHosts(deps.readHosts())
189
+ if (edited.changed) {
190
+ deps.writeHosts(edited.content)
191
+ await deps.flushDns().catch(() => {})
192
+ }
193
+ }
194
+ }
195
+ catch (error) {
196
+ deps.log(`hosts cleanup failed: ${(error as Error).message}`)
197
+ }
198
+
199
+ // The new session owns Caddy now; stopping it here would kill a server
200
+ // this generation never started.
201
+ if (generation === startedAt)
202
+ await stopCaddy()
203
+ })()
204
+ }
205
+ return teardown
206
+ }
207
+
208
+ async function sweepStaleEntries(): Promise<boolean> {
209
+ try {
210
+ const edited = removeMarkedHosts(deps.readHosts())
211
+ if (!edited.changed)
212
+ return false
213
+ deps.writeHosts(edited.content)
214
+ await flushDnsBestEffort(deps)
215
+ return true
216
+ }
217
+ catch (error) {
218
+ deps.log(`stale hosts cleanup failed: ${(error as Error).message}`)
219
+ return false
220
+ }
221
+ }
222
+
223
+ async function handle(request: HelperRequest, client?: object): Promise<HelperResponse> {
224
+ try {
225
+ switch (request.method) {
226
+ case 'ping':
227
+ return { id: request.id, ok: true, result: { pid: process.pid } }
228
+
229
+ case 'hosts.add': {
230
+ const { ip, host } = request.params
231
+ if (!isSafeIp(ip) || !isSafeHost(host))
232
+ return { id: request.id, ok: false, error: { message: 'invalid hosts entry' } }
233
+
234
+ const edited = addHost(deps.readHosts(), ip, host)
235
+ if (edited.changed)
236
+ deps.writeHosts(edited.content)
237
+ addHostOwner(client, ip, host)
238
+ await flushDnsBestEffort(deps)
239
+ return { id: request.id, ok: true }
240
+ }
241
+
242
+ case 'hosts.remove': {
243
+ const { ip, host } = request.params
244
+ if (!isSafeIp(ip) || !isSafeHost(host))
245
+ return { id: request.id, ok: false, error: { message: 'invalid hosts entry' } }
246
+
247
+ // Another project may still need this domain; only the last holder
248
+ // removes the mapping.
249
+ const last = releaseHostOwner(client, ip, host)
250
+ if (!last)
251
+ return { id: request.id, ok: true, result: { kept: true } }
252
+
253
+ const edited = removeHost(deps.readHosts(), ip, host)
254
+ if (edited.changed)
255
+ deps.writeHosts(edited.content)
256
+ await flushDnsBestEffort(deps)
257
+ return { id: request.id, ok: true }
258
+ }
259
+
260
+ case 'port.free': {
261
+ const { port } = request.params
262
+ if (!isAllowedFreePort(port))
263
+ return { id: request.id, ok: false, error: { message: `port ${port} is not releasable` } }
264
+
265
+ // Caddy belongs to this helper, so a port sweep must never kill it:
266
+ // the pid is the one serving every other project's domains.
267
+ const killed = await deps.freePort(port, caddy?.pid)
268
+ return { id: request.id, ok: true, result: { killed } }
269
+ }
270
+
271
+ case 'caddy.start': {
272
+ // Idempotent: several dev servers share one Caddy, so a second start
273
+ // must attach to the running process instead of restarting it.
274
+ if (caddy && caddy.running !== false)
275
+ return { id: request.id, ok: true, result: { pid: caddy.pid, started: false } }
276
+
277
+ caddy = deps.startCaddy(broadcast)
278
+ return { id: request.id, ok: true, result: { pid: caddy.pid, started: true } }
279
+ }
280
+
281
+ case 'caddy.stop': {
282
+ await stopCaddy()
283
+ return { id: request.id, ok: true }
284
+ }
285
+
286
+ case 'ca.trust': {
287
+ const result = await deps.trustLocalCa()
288
+ return { id: request.id, ok: true, result }
289
+ }
290
+
291
+ default:
292
+ return { id: (request as HelperRequest).id, ok: false, error: { message: 'unknown method' } }
293
+ }
294
+ }
295
+ catch (error) {
296
+ return { id: request.id, ok: false, error: { message: (error as Error).message } }
297
+ }
298
+ }
299
+
300
+ return {
301
+ handle,
302
+ attach(client) {
303
+ clients.add(client)
304
+ // Only the newest generation may clean up; an in-flight teardown started
305
+ // before this call is abandoned rather than cancelled mid-write.
306
+ generation++
307
+ teardown = undefined
308
+ },
309
+ async detach(client) {
310
+ clients.delete(client)
311
+
312
+ // Drop only the mappings this client was the last holder of, so a project
313
+ // that exits leaves the domains another project still serves alone.
314
+ for (const [id, owners] of [...hostOwners]) {
315
+ if (!owners.delete(client) || owners.size > 0)
316
+ continue
317
+
318
+ hostOwners.delete(id)
319
+ const [ip, host] = id.split(' ')
320
+ try {
321
+ const edited = removeHost(deps.readHosts(), ip, host)
322
+ if (edited.changed) {
323
+ deps.writeHosts(edited.content)
324
+ await flushDnsBestEffort(deps)
325
+ }
326
+ }
327
+ catch (error) {
328
+ deps.log(`hosts cleanup failed: ${(error as Error).message}`)
329
+ }
330
+ }
331
+
332
+ if (clients.size === 0)
333
+ await dispose()
334
+ },
335
+ dispose,
336
+ sweepStaleEntries,
337
+ clientCount: () => clients.size,
338
+ }
339
+ }
340
+
341
+ export interface NodeHelperDepsOptions {
342
+ hostsPath?: string
343
+ caddyBin: string
344
+ caddyConfigPath: string
345
+ /** Admin API socket; `ca.trust` talks to it instead of the TCP default. */
346
+ caddyAdminSocket?: string
347
+ caddyCwd?: string
348
+ caddyEnv?: Record<string, string>
349
+ /** Caddy storage directory; handed back to the user when Caddy stops. */
350
+ caddyStoragePath?: string
351
+ /** Owner that receives the storage directory after Caddy stops. */
352
+ storageOwner?: { uid: number, gid: number }
353
+ log?: (message: string) => void
354
+ }
355
+
356
+ /** Best-effort recursive chown, skipping anything it cannot read. */
357
+ async function reclaimTree(target: string, uid: number, gid: number): Promise<string[]> {
358
+ const reclaimed: string[] = []
359
+
360
+ const walk = (path: string): void => {
361
+ let stats
362
+ try {
363
+ // `lstat`: a symlink must never redirect a root chown to its target.
364
+ stats = lstatSync(path)
365
+ }
366
+ catch {
367
+ return
368
+ }
369
+
370
+ try {
371
+ lchownSync(path, uid, gid)
372
+ reclaimed.push(path)
373
+ }
374
+ catch {}
375
+
376
+ if (!stats.isDirectory())
377
+ return
378
+
379
+ let entries: string[]
380
+ try {
381
+ entries = readdirSync(path)
382
+ }
383
+ catch {
384
+ return
385
+ }
386
+
387
+ for (const entry of entries)
388
+ walk(join(path, entry))
389
+ }
390
+
391
+ walk(target)
392
+ return reclaimed
393
+ }
394
+
395
+ /** Installs the Caddy root CA using helper-owned commands. */
396
+ async function trustLocalCaWithSystemTools(
397
+ caddyBin: string,
398
+ adminSocket: string | undefined,
399
+ ): Promise<{ trusted: boolean, output: string }> {
400
+ // `caddy trust` reads the CA through the admin API, which only exists while
401
+ // Caddy is serving; the plugin calls this after Caddy is up. The admin API is
402
+ // a unix socket when the template enabled one, so the address follows it
403
+ // instead of the old TCP default.
404
+ const address = adminSocket ? `unix/${adminSocket}` : '127.0.0.1:2019'
405
+ try {
406
+ const { stdout, stderr } = await execFileAsync(
407
+ caddyBin,
408
+ ['trust', '--ca', 'local', '--address', address],
409
+ { timeout: 120_000 },
410
+ )
411
+ return { trusted: true, output: `${stdout}${stderr}`.trim() }
412
+ }
413
+ catch (error) {
414
+ const output = String((error as { stdout?: string }).stdout ?? '') + String((error as { stderr?: string }).stderr ?? '')
415
+ if (/already|not modified|no changes/i.test(output))
416
+ return { trusted: true, output: output.trim() }
417
+ throw new Error(output.trim() || (error as Error).message)
418
+ }
419
+ }
420
+
421
+ /** Production wiring for the helper process. */
422
+ export function createNodeHelperDeps(options: NodeHelperDepsOptions): HelperDeps {
423
+ const hostsPath = options.hostsPath ?? HOSTS_PATH
424
+ const log = options.log ?? ((message: string) => process.stdout.write(`${message}\n`))
425
+ let caddy: CaddyChild | undefined
426
+
427
+ // Caddy runs in its own session, so it no longer dies with this process. The
428
+ // `exit` hook is the last chance to take it down; it must stay synchronous,
429
+ // which `kill` is. Nothing runs after SIGKILL, and that case is covered by
430
+ // the next start freeing the port.
431
+ process.on('exit', () => {
432
+ if (caddy?.pid === undefined)
433
+ return
434
+ try {
435
+ process.kill(caddy.pid, 'SIGTERM')
436
+ }
437
+ catch {}
438
+ })
439
+
440
+ return {
441
+ readHosts: () => readFileSync(hostsPath, 'utf8'),
442
+ writeHosts: (content) => {
443
+ writeFileSync(hostsPath, content)
444
+ },
445
+ flushDns: async () => {
446
+ const candidates = FLUSH_COMMANDS[platform()] ?? [...FLUSH_FALLBACK]
447
+ let lastError: Error | undefined
448
+
449
+ for (const candidate of candidates) {
450
+ try {
451
+ await execFileAsync(candidate.file, candidate.args)
452
+ return
453
+ }
454
+ catch (error) {
455
+ lastError = error as Error
456
+ }
457
+ }
458
+
459
+ throw lastError ?? new Error('no DNS cache flush command for this platform')
460
+ },
461
+ freePort: async (port, keepPid) => {
462
+ // `lsof` exits with 1 when nothing matches, which is the normal case for
463
+ // a free port; only its stdout carries the answer.
464
+ const pids = platform() === 'win32'
465
+ ? parseWindowsListeningPids((await execFileAsync('netstat', ['-ano'])).stdout, port)
466
+ : parseListeningPids((await execFileQuiet('lsof', [`-ti:${port}`, '-sTCP:LISTEN'])).stdout)
467
+
468
+ const killed: number[] = []
469
+ for (const pid of pids) {
470
+ // Never kill the helper itself or the Caddy it owns: that Caddy serves
471
+ // every project sharing this helper, not just the caller's.
472
+ if (pid === process.pid || (keepPid !== undefined && pid === keepPid))
473
+ continue
474
+ try {
475
+ process.kill(pid, 'SIGTERM')
476
+ killed.push(pid)
477
+ }
478
+ catch {}
479
+ }
480
+ return killed
481
+ },
482
+ caddyBin: options.caddyBin,
483
+ caddyConfigPath: options.caddyConfigPath,
484
+ caddyCwd: options.caddyCwd ?? process.cwd(),
485
+ caddyEnv: options.caddyEnv ?? {},
486
+ trustLocalCa: () => trustLocalCaWithSystemTools(options.caddyBin, options.caddyAdminSocket),
487
+ caddyStoragePath: options.caddyStoragePath,
488
+ storageOwner: options.storageOwner,
489
+ startCaddy: (emit) => {
490
+ // `--watch` makes Caddy reload when another project rewrites the shared
491
+ // Caddyfile. Without it the second project's route never takes effect.
492
+ //
493
+ // `detached` puts Caddy in its own session. It shares one Caddy between
494
+ // projects, so the Ctrl+C that stops one dev server must not reach it:
495
+ // the whole foreground process group gets SIGINT, and Caddy exits on
496
+ // SIGINT. `kill()` still targets the pid, and the exit event still fires.
497
+ // Windows has no process groups to escape and `detached` would open a
498
+ // console window there, so it stays off.
499
+ const child = spawn(options.caddyBin, ['run', '--adapter', 'caddyfile', '--config', options.caddyConfigPath, '--watch'], {
500
+ cwd: options.caddyCwd ?? process.cwd(),
501
+ env: { ...process.env, ...options.caddyEnv },
502
+ stdio: ['ignore', 'pipe', 'pipe'],
503
+ detached: platform() !== 'win32',
504
+ })
505
+ const handle: CaddyChild & { running?: boolean } = {
506
+ pid: child.pid,
507
+ kill: signal => child.kill(signal),
508
+ running: true,
509
+ }
510
+ child.stdout?.on('data', (data: Buffer) => emit({ type: 'event', kind: 'caddy.stdout', data: data.toString() }))
511
+ child.stderr?.on('data', (data: Buffer) => emit({ type: 'event', kind: 'caddy.stderr', data: data.toString() }))
512
+ child.on('exit', (code, signal) => {
513
+ // Recorded before broadcasting so a later `caddy.start` knows it has to
514
+ // spawn again rather than reuse a dead process.
515
+ handle.running = false
516
+ emit({ type: 'event', kind: 'caddy.exit', code, signal })
517
+ })
518
+ caddy = handle
519
+ return handle
520
+ },
521
+ stopCaddy: async () => {
522
+ const current = caddy
523
+ caddy = undefined
524
+ if (!current)
525
+ return
526
+ try {
527
+ current.kill('SIGTERM')
528
+ }
529
+ catch {}
530
+ },
531
+ log,
532
+ }
533
+ }
534
+
535
+ /**
536
+ * Serves requests on an authenticated connection. When the client disappears —
537
+ * including a `SIGKILL` of the plugin process — the socket closes and `dispose`
538
+ * restores the machine state.
539
+ */
540
+ export function attachSocket(socket: Socket, runtime: HelperRuntime, log: (message: string) => void): void {
541
+ let buffer = ''
542
+ runtime.attach(socket)
543
+
544
+ socket.on('data', (chunk: Buffer) => {
545
+ buffer += chunk.toString('utf8')
546
+ let index = buffer.indexOf('\n')
547
+ while (index >= 0) {
548
+ const line = buffer.slice(0, index)
549
+ buffer = buffer.slice(index + 1)
550
+ index = buffer.indexOf('\n')
551
+
552
+ if (!line.trim())
553
+ continue
554
+
555
+ let request: HelperRequest
556
+ try {
557
+ request = JSON.parse(line) as HelperRequest
558
+ }
559
+ catch (error) {
560
+ log(`invalid request: ${(error as Error).message}`)
561
+ continue
562
+ }
563
+
564
+ void runtime.handle(request, socket).then((response) => {
565
+ if (!socket.destroyed)
566
+ socket.write(serializeMessage(response))
567
+ })
568
+ }
569
+ })
570
+
571
+ socket.on('error', (error) => {
572
+ log(`connection error: ${error.message}`)
573
+ })
574
+
575
+ socket.on('close', () => {
576
+ void runtime.detach(socket)
577
+ })
578
+ }