unplugin-https-reverse-proxy 4.1.0-beta.3 → 4.1.0-beta.5
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/dist/{chunk-HBXMM25C.js → chunk-A3KSOGWY.js} +272 -652
- package/dist/esbuild.cjs +275 -655
- package/dist/esbuild.js +1 -1
- package/dist/index.cjs +275 -655
- package/dist/index.js +1 -1
- package/dist/nuxt.cjs +273 -653
- package/dist/nuxt.js +1 -1
- package/dist/privileged-helper.cjs +110 -13
- package/dist/privileged-helper.js +111 -14
- package/dist/rollup.cjs +275 -655
- package/dist/rollup.js +1 -1
- package/dist/rspack.cjs +275 -655
- package/dist/rspack.js +1 -1
- package/dist/vite.cjs +275 -655
- package/dist/vite.js +1 -1
- package/dist/webpack.cjs +275 -655
- package/dist/webpack.js +1 -1
- package/package.json +1 -1
- package/src/caddy/config.ts +176 -57
- package/src/caddy/index.ts +82 -125
- package/src/privileged/bootstrap.ts +83 -2
- package/src/privileged/launch.ts +46 -9
- package/src/privileged/server.ts +95 -14
- package/src/privileged/session.ts +63 -14
|
@@ -2,8 +2,9 @@ import type { Socket } from 'node:net'
|
|
|
2
2
|
import type { ClientHandshake } from './protocol'
|
|
3
3
|
import type { HelperDeps, HelperRuntime } from './server'
|
|
4
4
|
import { Buffer } from 'node:buffer'
|
|
5
|
-
import { chmodSync, chownSync, rmSync } from 'node:fs'
|
|
5
|
+
import { chmodSync, chownSync, existsSync, readFileSync, rmSync } from 'node:fs'
|
|
6
6
|
import { createServer } from 'node:net'
|
|
7
|
+
import { dirname, join } from 'node:path'
|
|
7
8
|
import process from 'node:process'
|
|
8
9
|
import { attachSocket, createHelperRuntime, createNodeHelperDeps } from './server'
|
|
9
10
|
|
|
@@ -26,6 +27,14 @@ export interface HelperBootstrapOptions {
|
|
|
26
27
|
ownerGid?: number
|
|
27
28
|
/** Caddy storage directory, handed back to the owner when Caddy stops. */
|
|
28
29
|
caddyStoragePath?: string
|
|
30
|
+
/**
|
|
31
|
+
* How long the helper lingers with no clients before exiting.
|
|
32
|
+
*
|
|
33
|
+
* Kept short so a helper nobody is using does not stay on the machine as an
|
|
34
|
+
* idle root process. Within the same terminal a restart usually happens
|
|
35
|
+
* sooner than this and still reuses the running helper.
|
|
36
|
+
*/
|
|
37
|
+
idleTimeoutMs?: number
|
|
29
38
|
deps?: HelperDeps
|
|
30
39
|
log?: (message: string) => void
|
|
31
40
|
}
|
|
@@ -37,6 +46,9 @@ export interface RunningHelper {
|
|
|
37
46
|
|
|
38
47
|
const HANDSHAKE_TIMEOUT_MS = 120_000
|
|
39
48
|
|
|
49
|
+
/** Default idle lifetime after the last client leaves. */
|
|
50
|
+
export const DEFAULT_IDLE_TIMEOUT_MS = 30_000
|
|
51
|
+
|
|
40
52
|
/**
|
|
41
53
|
* Runs the privileged side of the plugin: listens on a local socket, accepts
|
|
42
54
|
* only a client presenting the shared token, then serves until that client
|
|
@@ -70,12 +82,26 @@ export async function startHelper(options: HelperBootstrapOptions): Promise<Runn
|
|
|
70
82
|
catch {}
|
|
71
83
|
|
|
72
84
|
const connections = new Set<Socket>()
|
|
85
|
+
const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS
|
|
86
|
+
let idleTimer: ReturnType<typeof setTimeout> | undefined
|
|
87
|
+
|
|
88
|
+
const stopIdleTimer = (): void => {
|
|
89
|
+
if (idleTimer) {
|
|
90
|
+
clearTimeout(idleTimer)
|
|
91
|
+
idleTimer = undefined
|
|
92
|
+
}
|
|
93
|
+
}
|
|
73
94
|
|
|
74
95
|
const server = createServer((socket) => {
|
|
75
96
|
let buffer = ''
|
|
76
97
|
let authenticated = false
|
|
98
|
+
stopIdleTimer()
|
|
77
99
|
connections.add(socket)
|
|
78
|
-
socket.on('close', () =>
|
|
100
|
+
socket.on('close', () => {
|
|
101
|
+
connections.delete(socket)
|
|
102
|
+
if (connections.size === 0)
|
|
103
|
+
scheduleIdleExit()
|
|
104
|
+
})
|
|
79
105
|
|
|
80
106
|
const timer = setTimeout(() => {
|
|
81
107
|
if (!authenticated)
|
|
@@ -127,11 +153,41 @@ export async function startHelper(options: HelperBootstrapOptions): Promise<Runn
|
|
|
127
153
|
socket.on('error', () => {})
|
|
128
154
|
})
|
|
129
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Exits once nobody needs the helper.
|
|
158
|
+
*
|
|
159
|
+
* Without this, every dev server that ever ran leaves a root process behind.
|
|
160
|
+
* The socket and token are removed too, but only while they still carry this
|
|
161
|
+
* process's identity: a newer helper may already own them.
|
|
162
|
+
*/
|
|
163
|
+
function scheduleIdleExit(): void {
|
|
164
|
+
if (idleTimer || connections.size > 0)
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
idleTimer = setTimeout(() => {
|
|
168
|
+
idleTimer = undefined
|
|
169
|
+
if (connections.size > 0)
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
log('no clients left; shutting down')
|
|
173
|
+
removeOwnSocket(socketPath, token)
|
|
174
|
+
server.close(() => process.exit(0))
|
|
175
|
+
// A listener with no clients closes synchronously, but keep a backstop so
|
|
176
|
+
// the process cannot linger if a close callback never arrives.
|
|
177
|
+
setTimeout(() => process.exit(0), 1000).unref?.()
|
|
178
|
+
}, idleTimeoutMs)
|
|
179
|
+
idleTimer.unref?.()
|
|
180
|
+
}
|
|
181
|
+
|
|
130
182
|
await new Promise<void>((resolve, reject) => {
|
|
131
183
|
server.once('error', reject)
|
|
132
184
|
server.listen(socketPath, () => resolve())
|
|
133
185
|
})
|
|
134
186
|
|
|
187
|
+
// A helper nobody ever connects to is as useless as one whose clients left:
|
|
188
|
+
// start the timer now so a failed handshake does not strand it.
|
|
189
|
+
scheduleIdleExit()
|
|
190
|
+
|
|
135
191
|
try {
|
|
136
192
|
// The client is not root: hand the socket back to the developer and keep it
|
|
137
193
|
// private to them. Without the chown, `connect()` fails with EACCES and the
|
|
@@ -163,6 +219,31 @@ export async function startHelper(options: HelperBootstrapOptions): Promise<Runn
|
|
|
163
219
|
}
|
|
164
220
|
}
|
|
165
221
|
|
|
222
|
+
/**
|
|
223
|
+
* Removes this helper's socket and token, but only if they are still its own.
|
|
224
|
+
*
|
|
225
|
+
* A newer helper may have taken both over; deleting them then would break a
|
|
226
|
+
* session this process never served.
|
|
227
|
+
*/
|
|
228
|
+
function removeOwnSocket(socketPath: string, token: string): void {
|
|
229
|
+
if (process.platform === 'win32')
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
const tokensDir = dirname(socketPath)
|
|
234
|
+
const tokenFile = join(tokensDir, 'helper.token')
|
|
235
|
+
const current = existsSync(tokenFile) ? readFileSync(tokenFile, 'utf8').trim() : undefined
|
|
236
|
+
if (current === token)
|
|
237
|
+
rmSync(tokenFile, { force: true })
|
|
238
|
+
}
|
|
239
|
+
catch {}
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
rmSync(socketPath, { force: true })
|
|
243
|
+
}
|
|
244
|
+
catch {}
|
|
245
|
+
}
|
|
246
|
+
|
|
166
247
|
/** Sockets left behind by a killed helper cannot be reused; remove them first. */
|
|
167
248
|
export function removeStaleSocket(socketPath: string): void {
|
|
168
249
|
if (process.platform === 'win32')
|
package/src/privileged/launch.ts
CHANGED
|
@@ -70,10 +70,10 @@ export function describePasswordReason(target: string, https: boolean): string[]
|
|
|
70
70
|
* Returns `denied` when the user dismissed the prompt, so the caller can keep
|
|
71
71
|
* the dev server running instead of treating it as a crash.
|
|
72
72
|
*/
|
|
73
|
-
export function authenticateSudo(
|
|
73
|
+
export async function authenticateSudo(
|
|
74
74
|
sudoCommand = 'sudo',
|
|
75
75
|
options: { target?: string, https?: boolean } = {},
|
|
76
|
-
): { ok: boolean, denied?: boolean, message?: string } {
|
|
76
|
+
): Promise<{ ok: boolean, denied?: boolean, message?: string }> {
|
|
77
77
|
if (platform() === 'win32')
|
|
78
78
|
return { ok: true }
|
|
79
79
|
|
|
@@ -86,16 +86,53 @@ export function authenticateSudo(
|
|
|
86
86
|
process.stderr.write('\n')
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
// `sudo -v` inherits the terminal, so Ctrl+C reaches this process too. Without
|
|
90
|
+
// a handler the default action kills the dev server, which reads as "the
|
|
91
|
+
// proxy crashed my dev server" when the user only meant to skip it.
|
|
92
|
+
let interrupted = false
|
|
93
|
+
const onSigint = (): void => {
|
|
94
|
+
interrupted = true
|
|
95
|
+
}
|
|
96
|
+
process.on('SIGINT', onSigint)
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const { status, signal } = await runPrompt(sudoCommand)
|
|
100
|
+
if (status === 0)
|
|
101
|
+
return { ok: true }
|
|
91
102
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
103
|
+
// `sudo` exits 1 on a wrong password and dies by the signal on Ctrl+C.
|
|
104
|
+
// Both are checked: a real Ctrl+C reaches this process and the child
|
|
105
|
+
// together, while a signal aimed at the child alone only shows up here.
|
|
106
|
+
const cancelled = interrupted || signal === 'SIGINT'
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
denied: true,
|
|
110
|
+
message: cancelled
|
|
111
|
+
? 'administrator permission was cancelled'
|
|
112
|
+
: 'administrator permission is required to edit the hosts file and bind ports 80/443',
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
process.off('SIGINT', onSigint)
|
|
96
117
|
}
|
|
97
118
|
}
|
|
98
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Runs `sudo -v` with the terminal attached.
|
|
122
|
+
*
|
|
123
|
+
* Spawned asynchronously rather than with `spawnSync`: the synchronous form
|
|
124
|
+
* blocks the event loop, so the SIGINT handler above never runs and the exit
|
|
125
|
+
* looks like a plain failure instead of a cancellation.
|
|
126
|
+
*/
|
|
127
|
+
function runPrompt(sudoCommand: string): Promise<{ status: number | null, signal: NodeJS.Signals | null }> {
|
|
128
|
+
return new Promise((resolve) => {
|
|
129
|
+
const child = spawn(sudoCommand, ['-v'], { stdio: 'inherit' })
|
|
130
|
+
|
|
131
|
+
child.on('error', () => resolve({ status: 1, signal: null }))
|
|
132
|
+
child.on('exit', (status, signal) => resolve({ status, signal }))
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
99
136
|
/** The user dismissed the password prompt; this is a choice, not a failure. */
|
|
100
137
|
export class PrivilegeDeniedError extends Error {
|
|
101
138
|
constructor(message: string) {
|
|
@@ -132,7 +169,7 @@ export async function startPrivilegedHelper(options: LaunchOptions): Promise<Pri
|
|
|
132
169
|
} = options
|
|
133
170
|
|
|
134
171
|
if (!skipAuth && !direct) {
|
|
135
|
-
const auth = authenticateSudo(sudoCommand, options.reason)
|
|
172
|
+
const auth = await authenticateSudo(sudoCommand, options.reason)
|
|
136
173
|
if (!auth.ok) {
|
|
137
174
|
const message = auth.message ?? 'administrator permission is required'
|
|
138
175
|
throw auth.denied ? new PrivilegeDeniedError(message) : new Error(message)
|
package/src/privileged/server.ts
CHANGED
|
@@ -46,6 +46,8 @@ const FLUSH_FALLBACK = [{ file: 'killall', args: ['-HUP', 'mDNSResponder'] }] as
|
|
|
46
46
|
export interface CaddyChild {
|
|
47
47
|
pid?: number
|
|
48
48
|
kill: (signal?: NodeJS.Signals) => boolean
|
|
49
|
+
/** False once the process has exited; undefined when unknown. */
|
|
50
|
+
running?: boolean
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
/**
|
|
@@ -60,7 +62,8 @@ export interface HelperDeps {
|
|
|
60
62
|
readHosts: () => string
|
|
61
63
|
writeHosts: (content: string) => void
|
|
62
64
|
flushDns: () => Promise<void>
|
|
63
|
-
|
|
65
|
+
/** Releases a port; never kills the pid in `keepPid`. */
|
|
66
|
+
freePort: (port: number, keepPid?: number) => Promise<number[]>
|
|
64
67
|
/** Caddy binary and the Caddyfile it must load, both fixed at start-up. */
|
|
65
68
|
caddyBin: string
|
|
66
69
|
caddyConfigPath: string
|
|
@@ -91,7 +94,8 @@ async function flushDnsBestEffort(deps: HelperDeps): Promise<void> {
|
|
|
91
94
|
}
|
|
92
95
|
|
|
93
96
|
export interface HelperRuntime {
|
|
94
|
-
|
|
97
|
+
/** `client` identifies the caller so shared host mappings are reference-counted. */
|
|
98
|
+
handle: (request: HelperRequest, client?: object) => Promise<HelperResponse>
|
|
95
99
|
/** Registers a connected client so Caddy output reaches it. */
|
|
96
100
|
attach: (client: object) => void
|
|
97
101
|
/** Last client gone: stop Caddy and drop the hosts entries this plugin wrote. */
|
|
@@ -114,6 +118,37 @@ export function createHelperRuntime(deps: HelperDeps): HelperRuntime {
|
|
|
114
118
|
let generation = 0
|
|
115
119
|
let caddy: CaddyChild | undefined
|
|
116
120
|
|
|
121
|
+
// Which client asked for which host. Two projects may map the same domain, so
|
|
122
|
+
// a mapping is only removed once its last holder is gone.
|
|
123
|
+
const hostOwners = new Map<string, Set<object>>()
|
|
124
|
+
|
|
125
|
+
const hostId = (ip: string, host: string): string => `${ip} ${host.toLowerCase()}`
|
|
126
|
+
|
|
127
|
+
function addHostOwner(client: object | undefined, ip: string, host: string): void {
|
|
128
|
+
const id = hostId(ip, host)
|
|
129
|
+
let owners = hostOwners.get(id)
|
|
130
|
+
if (!owners) {
|
|
131
|
+
owners = new Set()
|
|
132
|
+
hostOwners.set(id, owners)
|
|
133
|
+
}
|
|
134
|
+
owners.add(client ?? deps)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** True when no other client still needs this mapping. */
|
|
138
|
+
function releaseHostOwner(client: object | undefined, ip: string, host: string): boolean {
|
|
139
|
+
const id = hostId(ip, host)
|
|
140
|
+
const owners = hostOwners.get(id)
|
|
141
|
+
if (!owners)
|
|
142
|
+
return true
|
|
143
|
+
|
|
144
|
+
owners.delete(client ?? deps)
|
|
145
|
+
if (owners.size > 0)
|
|
146
|
+
return false
|
|
147
|
+
|
|
148
|
+
hostOwners.delete(id)
|
|
149
|
+
return true
|
|
150
|
+
}
|
|
151
|
+
|
|
117
152
|
const broadcast = (event: HelperEvent): void => {
|
|
118
153
|
const line = serializeMessage(event)
|
|
119
154
|
for (const client of clients) {
|
|
@@ -183,7 +218,7 @@ export function createHelperRuntime(deps: HelperDeps): HelperRuntime {
|
|
|
183
218
|
}
|
|
184
219
|
}
|
|
185
220
|
|
|
186
|
-
async function handle(request: HelperRequest): Promise<HelperResponse> {
|
|
221
|
+
async function handle(request: HelperRequest, client?: object): Promise<HelperResponse> {
|
|
187
222
|
try {
|
|
188
223
|
switch (request.method) {
|
|
189
224
|
case 'ping':
|
|
@@ -197,6 +232,7 @@ export function createHelperRuntime(deps: HelperDeps): HelperRuntime {
|
|
|
197
232
|
const edited = addHost(deps.readHosts(), ip, host)
|
|
198
233
|
if (edited.changed)
|
|
199
234
|
deps.writeHosts(edited.content)
|
|
235
|
+
addHostOwner(client, ip, host)
|
|
200
236
|
await flushDnsBestEffort(deps)
|
|
201
237
|
return { id: request.id, ok: true }
|
|
202
238
|
}
|
|
@@ -206,6 +242,12 @@ export function createHelperRuntime(deps: HelperDeps): HelperRuntime {
|
|
|
206
242
|
if (!isSafeIp(ip) || !isSafeHost(host))
|
|
207
243
|
return { id: request.id, ok: false, error: { message: 'invalid hosts entry' } }
|
|
208
244
|
|
|
245
|
+
// Another project may still need this domain; only the last holder
|
|
246
|
+
// removes the mapping.
|
|
247
|
+
const last = releaseHostOwner(client, ip, host)
|
|
248
|
+
if (!last)
|
|
249
|
+
return { id: request.id, ok: true, result: { kept: true } }
|
|
250
|
+
|
|
209
251
|
const edited = removeHost(deps.readHosts(), ip, host)
|
|
210
252
|
if (edited.changed)
|
|
211
253
|
deps.writeHosts(edited.content)
|
|
@@ -218,16 +260,20 @@ export function createHelperRuntime(deps: HelperDeps): HelperRuntime {
|
|
|
218
260
|
if (!isAllowedFreePort(port))
|
|
219
261
|
return { id: request.id, ok: false, error: { message: `port ${port} is not releasable` } }
|
|
220
262
|
|
|
221
|
-
|
|
263
|
+
// Caddy belongs to this helper, so a port sweep must never kill it:
|
|
264
|
+
// the pid is the one serving every other project's domains.
|
|
265
|
+
const killed = await deps.freePort(port, caddy?.pid)
|
|
222
266
|
return { id: request.id, ok: true, result: { killed } }
|
|
223
267
|
}
|
|
224
268
|
|
|
225
269
|
case 'caddy.start': {
|
|
226
|
-
|
|
227
|
-
|
|
270
|
+
// Idempotent: several dev servers share one Caddy, so a second start
|
|
271
|
+
// must attach to the running process instead of restarting it.
|
|
272
|
+
if (caddy && caddy.running !== false)
|
|
273
|
+
return { id: request.id, ok: true, result: { pid: caddy.pid, started: false } }
|
|
228
274
|
|
|
229
275
|
caddy = deps.startCaddy(broadcast)
|
|
230
|
-
return { id: request.id, ok: true, result: { pid: caddy.pid } }
|
|
276
|
+
return { id: request.id, ok: true, result: { pid: caddy.pid, started: true } }
|
|
231
277
|
}
|
|
232
278
|
|
|
233
279
|
case 'caddy.stop': {
|
|
@@ -260,6 +306,27 @@ export function createHelperRuntime(deps: HelperDeps): HelperRuntime {
|
|
|
260
306
|
},
|
|
261
307
|
async detach(client) {
|
|
262
308
|
clients.delete(client)
|
|
309
|
+
|
|
310
|
+
// Drop only the mappings this client was the last holder of, so a project
|
|
311
|
+
// that exits leaves the domains another project still serves alone.
|
|
312
|
+
for (const [id, owners] of [...hostOwners]) {
|
|
313
|
+
if (!owners.delete(client) || owners.size > 0)
|
|
314
|
+
continue
|
|
315
|
+
|
|
316
|
+
hostOwners.delete(id)
|
|
317
|
+
const [ip, host] = id.split(' ')
|
|
318
|
+
try {
|
|
319
|
+
const edited = removeHost(deps.readHosts(), ip, host)
|
|
320
|
+
if (edited.changed) {
|
|
321
|
+
deps.writeHosts(edited.content)
|
|
322
|
+
await flushDnsBestEffort(deps)
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
deps.log(`hosts cleanup failed: ${(error as Error).message}`)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
263
330
|
if (clients.size === 0)
|
|
264
331
|
await dispose()
|
|
265
332
|
},
|
|
@@ -367,7 +434,7 @@ export function createNodeHelperDeps(options: NodeHelperDepsOptions): HelperDeps
|
|
|
367
434
|
|
|
368
435
|
throw lastError ?? new Error('no DNS cache flush command for this platform')
|
|
369
436
|
},
|
|
370
|
-
freePort: async (port) => {
|
|
437
|
+
freePort: async (port, keepPid) => {
|
|
371
438
|
// `lsof` exits with 1 when nothing matches, which is the normal case for
|
|
372
439
|
// a free port; only its stdout carries the answer.
|
|
373
440
|
const pids = platform() === 'win32'
|
|
@@ -376,7 +443,9 @@ export function createNodeHelperDeps(options: NodeHelperDepsOptions): HelperDeps
|
|
|
376
443
|
|
|
377
444
|
const killed: number[] = []
|
|
378
445
|
for (const pid of pids) {
|
|
379
|
-
|
|
446
|
+
// Never kill the helper itself or the Caddy it owns: that Caddy serves
|
|
447
|
+
// every project sharing this helper, not just the caller's.
|
|
448
|
+
if (pid === process.pid || (keepPid !== undefined && pid === keepPid))
|
|
380
449
|
continue
|
|
381
450
|
try {
|
|
382
451
|
process.kill(pid, 'SIGTERM')
|
|
@@ -394,16 +463,28 @@ export function createNodeHelperDeps(options: NodeHelperDepsOptions): HelperDeps
|
|
|
394
463
|
caddyStoragePath: options.caddyStoragePath,
|
|
395
464
|
storageOwner: options.storageOwner,
|
|
396
465
|
startCaddy: (emit) => {
|
|
397
|
-
|
|
466
|
+
// `--watch` makes Caddy reload when another project rewrites the shared
|
|
467
|
+
// Caddyfile. Without it the second project's route never takes effect.
|
|
468
|
+
const child = spawn(options.caddyBin, ['run', '--adapter', 'caddyfile', '--config', options.caddyConfigPath, '--watch'], {
|
|
398
469
|
cwd: options.caddyCwd ?? process.cwd(),
|
|
399
470
|
env: { ...process.env, ...options.caddyEnv },
|
|
400
471
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
401
472
|
})
|
|
473
|
+
const handle: CaddyChild & { running?: boolean } = {
|
|
474
|
+
pid: child.pid,
|
|
475
|
+
kill: signal => child.kill(signal),
|
|
476
|
+
running: true,
|
|
477
|
+
}
|
|
402
478
|
child.stdout?.on('data', (data: Buffer) => emit({ type: 'event', kind: 'caddy.stdout', data: data.toString() }))
|
|
403
479
|
child.stderr?.on('data', (data: Buffer) => emit({ type: 'event', kind: 'caddy.stderr', data: data.toString() }))
|
|
404
|
-
child.on('exit', (code, signal) =>
|
|
405
|
-
|
|
406
|
-
|
|
480
|
+
child.on('exit', (code, signal) => {
|
|
481
|
+
// Recorded before broadcasting so a later `caddy.start` knows it has to
|
|
482
|
+
// spawn again rather than reuse a dead process.
|
|
483
|
+
handle.running = false
|
|
484
|
+
emit({ type: 'event', kind: 'caddy.exit', code, signal })
|
|
485
|
+
})
|
|
486
|
+
caddy = handle
|
|
487
|
+
return handle
|
|
407
488
|
},
|
|
408
489
|
stopCaddy: async () => {
|
|
409
490
|
const current = caddy
|
|
@@ -448,7 +529,7 @@ export function attachSocket(socket: Socket, runtime: HelperRuntime, log: (messa
|
|
|
448
529
|
continue
|
|
449
530
|
}
|
|
450
531
|
|
|
451
|
-
void runtime.handle(request).then((response) => {
|
|
532
|
+
void runtime.handle(request, socket).then((response) => {
|
|
452
533
|
if (!socket.destroyed)
|
|
453
534
|
socket.write(serializeMessage(response))
|
|
454
535
|
})
|
|
@@ -4,6 +4,7 @@ import { existsSync } from 'node:fs'
|
|
|
4
4
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
5
5
|
import { platform } from 'node:os'
|
|
6
6
|
import { join } from 'node:path'
|
|
7
|
+
import * as lockfile from 'proper-lockfile'
|
|
7
8
|
import { connectExistingHelper, createHelperToken, startPrivilegedHelper } from './launch'
|
|
8
9
|
|
|
9
10
|
export interface SessionScope {
|
|
@@ -50,26 +51,50 @@ export function sessionSocketPath(runtimeDir: string): string {
|
|
|
50
51
|
* Connects to the helper already serving this runtime directory, or starts a
|
|
51
52
|
* new one. Reuse matters for multi-instance dev servers: the second instance
|
|
52
53
|
* attaches to the running Caddy instead of asking for a password again.
|
|
54
|
+
*
|
|
55
|
+
* The check-then-start sequence runs under a lock. Two projects starting at the
|
|
56
|
+
* same moment would otherwise both fail to find a socket, both spawn a helper,
|
|
57
|
+
* and the second would delete the first one's socket while it was still
|
|
58
|
+
* serving.
|
|
53
59
|
*/
|
|
54
60
|
export async function openHelperSession(scope: SessionScope): Promise<OpenedSession> {
|
|
55
61
|
const socketPath = sessionSocketPath(scope.runtimeDir)
|
|
62
|
+
const release = await acquireStartLock(scope.runtimeDir)
|
|
56
63
|
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
client: reused,
|
|
64
|
-
reused: true,
|
|
65
|
-
close: async () => {
|
|
66
|
-
await reused.close()
|
|
67
|
-
},
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
}
|
|
64
|
+
try {
|
|
65
|
+
const reused = await tryReuse(socketPath, scope)
|
|
66
|
+
if (reused)
|
|
67
|
+
return reused
|
|
68
|
+
|
|
69
|
+
return await startNew(socketPath, scope)
|
|
71
70
|
}
|
|
71
|
+
finally {
|
|
72
|
+
release()
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function tryReuse(socketPath: string, scope: SessionScope): Promise<OpenedSession | undefined> {
|
|
77
|
+
if (!existsSync(socketPath) && platform() !== 'win32')
|
|
78
|
+
return undefined
|
|
79
|
+
|
|
80
|
+
const token = await readToken(scope.runtimeDir)
|
|
81
|
+
if (!token)
|
|
82
|
+
return undefined
|
|
83
|
+
|
|
84
|
+
const reused = await connectExistingHelper(socketPath, token)
|
|
85
|
+
if (!reused)
|
|
86
|
+
return undefined
|
|
72
87
|
|
|
88
|
+
return {
|
|
89
|
+
client: reused,
|
|
90
|
+
reused: true,
|
|
91
|
+
close: async () => {
|
|
92
|
+
await reused.close()
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function startNew(socketPath: string, scope: SessionScope): Promise<OpenedSession> {
|
|
73
98
|
const token = createHelperToken()
|
|
74
99
|
const started = await startPrivilegedHelper({
|
|
75
100
|
socketPath,
|
|
@@ -98,6 +123,30 @@ export async function openHelperSession(scope: SessionScope): Promise<OpenedSess
|
|
|
98
123
|
}
|
|
99
124
|
}
|
|
100
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Serialises "is a helper running? if not, start one" across processes.
|
|
128
|
+
*
|
|
129
|
+
* The lock file is separate from the socket: the socket only exists once a
|
|
130
|
+
* helper is listening, while this lock guards the window before that.
|
|
131
|
+
*/
|
|
132
|
+
async function acquireStartLock(runtimeDir: string): Promise<() => void> {
|
|
133
|
+
const lockPath = join(runtimeDir, 'helper.start.lock')
|
|
134
|
+
try {
|
|
135
|
+
await writeFile(lockPath, '', { flag: 'a' })
|
|
136
|
+
}
|
|
137
|
+
catch {}
|
|
138
|
+
|
|
139
|
+
const release = await lockfile.lock(lockPath, {
|
|
140
|
+
realpath: false,
|
|
141
|
+
stale: 30_000,
|
|
142
|
+
retries: { retries: 100, minTimeout: 50, maxTimeout: 200 },
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
return () => {
|
|
146
|
+
void release().catch(() => {})
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
101
150
|
async function readToken(runtimeDir: string): Promise<string | undefined> {
|
|
102
151
|
try {
|
|
103
152
|
const content = await readFile(tokenPath(runtimeDir), 'utf8')
|