unplugin-https-reverse-proxy 4.0.4 → 4.1.0-beta.2
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/README.md +30 -6
- package/dist/{chunk-5R33PUJP.js → chunk-7CA3EIIX.js} +681 -223
- package/dist/{chunk-2P7SUCQ3.js → chunk-EY3HXBJ4.js} +0 -26
- package/dist/chunk-RKHJN6KY.js +53 -0
- package/dist/esbuild.cjs +781 -300
- package/dist/esbuild.js +3 -2
- package/dist/index.cjs +781 -300
- package/dist/index.js +3 -2
- package/dist/nuxt.cjs +779 -290
- package/dist/nuxt.js +5 -9
- package/dist/privileged-helper.cjs +668 -0
- package/dist/privileged-helper.d.cts +2 -0
- package/dist/privileged-helper.d.ts +2 -0
- package/dist/privileged-helper.js +641 -0
- package/dist/rollup.cjs +781 -300
- package/dist/rollup.js +3 -2
- package/dist/rspack.cjs +781 -300
- package/dist/rspack.js +3 -2
- package/dist/utils.cjs +0 -28
- package/dist/utils.d.cts +1 -3
- package/dist/utils.d.ts +1 -3
- package/dist/utils.js +1 -5
- package/dist/vite.cjs +781 -300
- package/dist/vite.js +3 -2
- package/dist/webpack.cjs +781 -300
- package/dist/webpack.js +3 -2
- package/package.json +1 -4
- package/src/caddy/caddyfile.ts +1 -0
- package/src/caddy/index.ts +183 -110
- package/src/caddy/privileged.ts +118 -0
- package/src/caddy/utils.ts +0 -43
- package/src/index.ts +1 -15
- package/src/nuxt.ts +1 -6
- package/src/privileged/bootstrap.ts +174 -0
- package/src/privileged/client.ts +287 -0
- package/src/privileged/entry.ts +45 -0
- package/src/privileged/hosts-file.ts +142 -0
- package/src/privileged/launch.ts +193 -0
- package/src/privileged/ports.ts +45 -0
- package/src/privileged/protocol.ts +111 -0
- package/src/privileged/server.ts +465 -0
- package/src/privileged/session.ts +114 -0
- package/src/privileged/validation.ts +38 -0
- package/src/privileged/windows-elevation.ts +80 -0
- package/src/privileged-helper.ts +109 -0
- package/src/utils.ts +0 -28
- package/src/host/index.ts +0 -52
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { isIP } from 'node:net'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `/etc/hosts` edits are modelled as pure string transformations so the helper
|
|
5
|
+
* can be tested without touching the real file.
|
|
6
|
+
*
|
|
7
|
+
* `/etc/hosts` is user data: a line may map several names at once
|
|
8
|
+
* (`127.0.0.1 a.test b.test`). Editing one name must never drop the others, and
|
|
9
|
+
* entries this plugin did not write must survive every operation.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const HOSTS_MARKER = '# uhrp'
|
|
13
|
+
export const HOSTS_MARKER_PATTERN = /#\s*uhrp\b/
|
|
14
|
+
|
|
15
|
+
export interface HostsEdit {
|
|
16
|
+
content: string
|
|
17
|
+
changed: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface ParsedLine {
|
|
21
|
+
ip: string
|
|
22
|
+
hosts: string[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function detectEol(content: string): string {
|
|
26
|
+
return content.includes('\r\n') ? '\r\n' : '\n'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function toBodyLines(content: string): string[] {
|
|
30
|
+
const lines = content.split(/\r?\n/)
|
|
31
|
+
if (lines.length > 0 && lines[lines.length - 1] === '')
|
|
32
|
+
lines.pop()
|
|
33
|
+
return lines
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseLine(line: string): ParsedLine | undefined {
|
|
37
|
+
const withoutComment = line.replace(/#.*/, '').trim()
|
|
38
|
+
if (!withoutComment)
|
|
39
|
+
return undefined
|
|
40
|
+
const [ip, ...hosts] = withoutComment.split(/\s+/)
|
|
41
|
+
if (!ip || hosts.length === 0)
|
|
42
|
+
return undefined
|
|
43
|
+
return { ip, hosts }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isOwned(line: string): boolean {
|
|
47
|
+
return HOSTS_MARKER_PATTERN.test(line)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function mentionsHost(entry: ParsedLine, host: string): boolean {
|
|
51
|
+
return entry.hosts.some(candidate => candidate.toLowerCase() === host.toLowerCase())
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sameIpFamily(left: string, right: string): boolean {
|
|
55
|
+
const leftVersion = isIP(left)
|
|
56
|
+
return leftVersion !== 0 && leftVersion === isIP(right)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function rebuild(lines: string[], original: string, eol: string): HostsEdit {
|
|
60
|
+
const content = lines.length > 0 ? lines.join(eol) + eol : ''
|
|
61
|
+
return { content, changed: content !== original }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Drops `host` from its mapping and appends a marked entry for it.
|
|
66
|
+
*
|
|
67
|
+
* A user-written line that also maps other names is rewritten without `host`
|
|
68
|
+
* instead of being deleted, so `127.0.0.1 a.test b.test` keeps `b.test`.
|
|
69
|
+
*/
|
|
70
|
+
export function addHost(content: string, ip: string, host: string): HostsEdit {
|
|
71
|
+
const eol = detectEol(content)
|
|
72
|
+
const lines: string[] = []
|
|
73
|
+
|
|
74
|
+
for (const line of toBodyLines(content)) {
|
|
75
|
+
const entry = parseLine(line)
|
|
76
|
+
if (!entry || !mentionsHost(entry, host) || !sameIpFamily(entry.ip, ip)) {
|
|
77
|
+
lines.push(line)
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const remaining = entry.hosts.filter(candidate => candidate.toLowerCase() !== host.toLowerCase())
|
|
82
|
+
if (remaining.length === 0)
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
// Preserve the original comment when one exists so a user's annotation is
|
|
86
|
+
// not silently dropped by our edit.
|
|
87
|
+
const comment = line.match(/#.*/)?.[0] ?? ''
|
|
88
|
+
const rebuilt = `${entry.ip} ${remaining.join(' ')}${comment ? ` ${comment}` : ''}`
|
|
89
|
+
lines.push(rebuilt)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
lines.push(`${ip} ${host} ${HOSTS_MARKER}`)
|
|
93
|
+
return rebuild(lines, content, eol)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Removes `host` from the mapping to `ip`. Only lines this plugin wrote are
|
|
98
|
+
* removed outright; a user's line keeps its other names.
|
|
99
|
+
*/
|
|
100
|
+
export function removeHost(content: string, ip: string, host: string): HostsEdit {
|
|
101
|
+
const eol = detectEol(content)
|
|
102
|
+
const lines: string[] = []
|
|
103
|
+
|
|
104
|
+
for (const line of toBodyLines(content)) {
|
|
105
|
+
const entry = parseLine(line)
|
|
106
|
+
if (!entry || entry.ip !== ip || !mentionsHost(entry, host)) {
|
|
107
|
+
lines.push(line)
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const remaining = entry.hosts.filter(candidate => candidate.toLowerCase() !== host.toLowerCase())
|
|
112
|
+
if (remaining.length === 0)
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
const comment = line.match(/#.*/)?.[0] ?? ''
|
|
116
|
+
lines.push(`${entry.ip} ${remaining.join(' ')}${comment ? ` ${comment}` : ''}`)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return rebuild(lines, content, eol)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Drops every entry this plugin wrote, used to clean up after a crashed session. */
|
|
123
|
+
export function removeMarkedHosts(content: string): HostsEdit {
|
|
124
|
+
const eol = detectEol(content)
|
|
125
|
+
const lines = toBodyLines(content).filter(line => !isOwned(line))
|
|
126
|
+
return rebuild(lines, content, eol)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Hosts entries this plugin owns, without the marker. */
|
|
130
|
+
export function listMarkedHosts(content: string): Array<{ ip: string, host: string }> {
|
|
131
|
+
const result: Array<{ ip: string, host: string }> = []
|
|
132
|
+
for (const line of toBodyLines(content)) {
|
|
133
|
+
if (!isOwned(line))
|
|
134
|
+
continue
|
|
135
|
+
const entry = parseLine(line)
|
|
136
|
+
if (!entry)
|
|
137
|
+
continue
|
|
138
|
+
for (const host of entry.hosts)
|
|
139
|
+
result.push({ ip: entry.ip, host })
|
|
140
|
+
}
|
|
141
|
+
return result
|
|
142
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import type { Buffer } from 'node:buffer'
|
|
2
|
+
import type { PrivilegedClient } from './client'
|
|
3
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
4
|
+
import { randomBytes } from 'node:crypto'
|
|
5
|
+
import { platform } from 'node:os'
|
|
6
|
+
import process from 'node:process'
|
|
7
|
+
import { collectForwardedEnv, connectClient } from './client'
|
|
8
|
+
import { spawnElevatedOnWindows } from './windows-elevation'
|
|
9
|
+
|
|
10
|
+
export function createHelperToken(): string {
|
|
11
|
+
return randomBytes(24).toString('hex')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface LaunchOptions {
|
|
15
|
+
socketPath: string
|
|
16
|
+
token: string
|
|
17
|
+
/** Executable started as root (node, or tsx when running from source). */
|
|
18
|
+
helperEntry: string
|
|
19
|
+
/** Arguments placed before the helper's own `--socket/--token` flags. */
|
|
20
|
+
helperArgs?: string[]
|
|
21
|
+
sudoCommand?: string
|
|
22
|
+
/** Skips the interactive credential check; used by tests and Windows. */
|
|
23
|
+
skipAuth?: boolean
|
|
24
|
+
/**
|
|
25
|
+
* Starts the helper as the current user instead of going through sudo.
|
|
26
|
+
* Used by tests that need the helper protocol but not real privileges.
|
|
27
|
+
*/
|
|
28
|
+
direct?: boolean
|
|
29
|
+
env?: NodeJS.ProcessEnv
|
|
30
|
+
log?: (message: string) => void
|
|
31
|
+
/** How long to wait for the helper socket to accept the connection. */
|
|
32
|
+
connectTimeoutMs?: number
|
|
33
|
+
/**
|
|
34
|
+
* Caddy's storage directory. Caddy runs as root in the helper, so the helper
|
|
35
|
+
* hands this tree back to the developer when it stops Caddy.
|
|
36
|
+
*/
|
|
37
|
+
caddyStoragePath?: string
|
|
38
|
+
/** Caddy binary the helper is allowed to run, fixed for the whole session. */
|
|
39
|
+
caddyBin: string
|
|
40
|
+
/** Caddyfile the helper is allowed to load. */
|
|
41
|
+
caddyConfigPath: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Asks for the administrator password once, up front, on the dev terminal.
|
|
46
|
+
*
|
|
47
|
+
* The credential cache is keyed by terminal, so a later `sudo -n` only finds
|
|
48
|
+
* this entry when the child stays in the same session. That is why the helper
|
|
49
|
+
* is spawned without `detached`.
|
|
50
|
+
*/
|
|
51
|
+
export function authenticateSudo(sudoCommand = 'sudo'): { ok: boolean, message?: string } {
|
|
52
|
+
if (platform() === 'win32')
|
|
53
|
+
return { ok: true }
|
|
54
|
+
|
|
55
|
+
if (spawnSync(sudoCommand, ['-n', 'true'], { stdio: 'ignore' }).status === 0)
|
|
56
|
+
return { ok: true }
|
|
57
|
+
|
|
58
|
+
if (spawnSync(sudoCommand, ['-v'], { stdio: 'inherit' }).status === 0)
|
|
59
|
+
return { ok: true }
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
message: 'administrator permission is required to edit the hosts file and bind ports 80/443',
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface PrivilegedHelperHandle {
|
|
68
|
+
client: PrivilegedClient
|
|
69
|
+
close: () => Promise<void>
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Starts the privileged helper and connects to it.
|
|
74
|
+
*
|
|
75
|
+
* Only this child runs as root: the Node process that owns the dev server,
|
|
76
|
+
* its caches and `node_modules` keeps the developer's identity. The user's
|
|
77
|
+
* proxy/HOME settings reach Caddy through the helper's environment instead of
|
|
78
|
+
* `sudo -E`.
|
|
79
|
+
*/
|
|
80
|
+
export async function startPrivilegedHelper(options: LaunchOptions): Promise<PrivilegedHelperHandle> {
|
|
81
|
+
const {
|
|
82
|
+
socketPath,
|
|
83
|
+
token,
|
|
84
|
+
helperEntry,
|
|
85
|
+
helperArgs = [],
|
|
86
|
+
sudoCommand = 'sudo',
|
|
87
|
+
skipAuth = false,
|
|
88
|
+
direct = false,
|
|
89
|
+
env = process.env,
|
|
90
|
+
log,
|
|
91
|
+
connectTimeoutMs = 120_000,
|
|
92
|
+
} = options
|
|
93
|
+
|
|
94
|
+
if (!skipAuth && !direct) {
|
|
95
|
+
const auth = authenticateSudo(sudoCommand)
|
|
96
|
+
if (!auth.ok)
|
|
97
|
+
throw new Error(auth.message)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const forwarded = collectForwardedEnv(env)
|
|
101
|
+
const forwardedArgs = Object.entries(forwarded).map(([key, value]) => `${key}=${value}`)
|
|
102
|
+
// `helperEntry` is the runtime and `helperArgs` the script it must run, so
|
|
103
|
+
// the helper's own flags always come last. The owner ids travel with them
|
|
104
|
+
// because the helper only learns who its client is at handshake time.
|
|
105
|
+
const childArgv = [
|
|
106
|
+
...helperArgs,
|
|
107
|
+
'--socket',
|
|
108
|
+
socketPath,
|
|
109
|
+
'--token',
|
|
110
|
+
token,
|
|
111
|
+
...(typeof process.getuid === 'function' ? ['--owner-uid', String(process.getuid())] : []),
|
|
112
|
+
...(typeof process.getgid === 'function' ? ['--owner-gid', String(process.getgid())] : []),
|
|
113
|
+
...(options.caddyStoragePath ? ['--caddy-storage', options.caddyStoragePath] : []),
|
|
114
|
+
'--caddy',
|
|
115
|
+
options.caddyBin,
|
|
116
|
+
'--caddy-config',
|
|
117
|
+
options.caddyConfigPath,
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
let child: ReturnType<typeof spawn> | undefined
|
|
121
|
+
|
|
122
|
+
if (!direct && platform() === 'win32') {
|
|
123
|
+
// UAC: the elevated helper is a separate process and cannot inherit stdio,
|
|
124
|
+
// so it reports through the named pipe instead. The user's environment is
|
|
125
|
+
// recovered by the helper from its own process env, which Windows keeps.
|
|
126
|
+
await spawnElevatedOnWindows({ file: helperEntry, args: childArgv, cwd: process.cwd() })
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
const command = direct ? helperEntry : sudoCommand
|
|
130
|
+
const spawnArgs = direct
|
|
131
|
+
? childArgv
|
|
132
|
+
: ['-n', 'env', ...forwardedArgs, helperEntry, ...childArgv]
|
|
133
|
+
|
|
134
|
+
// No `detached`: sudo stores its credential per session, and `setsid()`
|
|
135
|
+
// would make the helper look like a different login, so `sudo -n` could not
|
|
136
|
+
// reuse the password entered a moment earlier. The stdio pipes keep the
|
|
137
|
+
// helper reachable from this process.
|
|
138
|
+
child = spawn(command, spawnArgs, {
|
|
139
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
child.stdout?.on('data', (data: Buffer) => log?.(data.toString().trimEnd()))
|
|
143
|
+
child.stderr?.on('data', (data: Buffer) => log?.(data.toString().trimEnd()))
|
|
144
|
+
|
|
145
|
+
const exited = new Promise<number | null>(resolve => child!.once('exit', code => resolve(code)))
|
|
146
|
+
|
|
147
|
+
// Refused credentials make the helper exit immediately; report that instead
|
|
148
|
+
// of waiting for the full connection timeout.
|
|
149
|
+
const earlyExit = await Promise.race([
|
|
150
|
+
exited,
|
|
151
|
+
new Promise<undefined>(resolve => setTimeout(() => resolve(undefined), 500)),
|
|
152
|
+
])
|
|
153
|
+
|
|
154
|
+
if (earlyExit !== undefined && earlyExit !== 0) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`privileged helper exited with code ${earlyExit}. `
|
|
157
|
+
+ 'Administrator permission was refused, or sudo is not available.',
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
const client = await connectClient({ socketPath, token, timeoutMs: connectTimeoutMs })
|
|
164
|
+
return {
|
|
165
|
+
client,
|
|
166
|
+
async close() {
|
|
167
|
+
await client.close().catch(() => {})
|
|
168
|
+
child?.unref()
|
|
169
|
+
},
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
try {
|
|
174
|
+
child?.kill()
|
|
175
|
+
}
|
|
176
|
+
catch {}
|
|
177
|
+
throw error
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Reuses a helper that is already running for this runtime directory. */
|
|
182
|
+
export async function connectExistingHelper(
|
|
183
|
+
socketPath: string,
|
|
184
|
+
token: string,
|
|
185
|
+
timeoutMs = 1500,
|
|
186
|
+
): Promise<PrivilegedClient | undefined> {
|
|
187
|
+
try {
|
|
188
|
+
return await connectClient({ socketPath, token, timeoutMs })
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return undefined
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for freeing a listening TCP port without shelling out to `npx`.
|
|
3
|
+
* `parseListeningPids` keeps the decision testable: it only turns command
|
|
4
|
+
* output into pids, never spawns anything.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Parses `lsof -ti:<port> -sTCP:LISTEN` output (one pid per line). */
|
|
8
|
+
export function parseListeningPids(output: string): number[] {
|
|
9
|
+
const pids = new Set<number>()
|
|
10
|
+
for (const line of output.split(/\r?\n/)) {
|
|
11
|
+
const trimmed = line.trim()
|
|
12
|
+
if (!trimmed)
|
|
13
|
+
continue
|
|
14
|
+
if (!/^\d+$/.test(trimmed))
|
|
15
|
+
continue
|
|
16
|
+
const pid = Number(trimmed)
|
|
17
|
+
if (Number.isSafeInteger(pid) && pid > 0)
|
|
18
|
+
pids.add(pid)
|
|
19
|
+
}
|
|
20
|
+
return [...pids]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Parses `netstat -ano` output for the owning pids of a listening port on Windows. */
|
|
24
|
+
export function parseWindowsListeningPids(output: string, port: number): number[] {
|
|
25
|
+
const pids = new Set<number>()
|
|
26
|
+
const suffix = `:${port}`
|
|
27
|
+
|
|
28
|
+
for (const line of output.split(/\r?\n/)) {
|
|
29
|
+
const columns = line.trim().split(/\s+/)
|
|
30
|
+
if (columns.length < 5)
|
|
31
|
+
continue
|
|
32
|
+
const protocol = columns[0].toUpperCase()
|
|
33
|
+
if (protocol !== 'TCP')
|
|
34
|
+
continue
|
|
35
|
+
if (!columns[1].endsWith(suffix))
|
|
36
|
+
continue
|
|
37
|
+
if (columns[3].toUpperCase() !== 'LISTENING')
|
|
38
|
+
continue
|
|
39
|
+
const pid = Number(columns[4])
|
|
40
|
+
if (Number.isSafeInteger(pid) && pid > 0)
|
|
41
|
+
pids.add(pid)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return [...pids]
|
|
45
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire protocol between the unprivileged plugin process and the privileged
|
|
3
|
+
* helper. Newline-delimited JSON keeps the helper dependency-free.
|
|
4
|
+
*
|
|
5
|
+
* The helper never executes a binary or uses a path supplied by the client:
|
|
6
|
+
* both are fixed when the helper starts (`--caddy` and `--caddy-config`), so a
|
|
7
|
+
* client that reaches the socket cannot turn the helper into a root shell.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type RpcId = number
|
|
11
|
+
|
|
12
|
+
export interface RpcErrorPayload {
|
|
13
|
+
message: string
|
|
14
|
+
code?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type HelperRequest =
|
|
18
|
+
| { id: RpcId, method: 'ping' }
|
|
19
|
+
| { id: RpcId, method: 'hosts.add', params: { ip: string, host: string } }
|
|
20
|
+
| { id: RpcId, method: 'hosts.remove', params: { ip: string, host: string } }
|
|
21
|
+
| { id: RpcId, method: 'port.free', params: { port: number } }
|
|
22
|
+
| { id: RpcId, method: 'caddy.start' }
|
|
23
|
+
| { id: RpcId, method: 'caddy.stop' }
|
|
24
|
+
| { id: RpcId, method: 'ca.trust' }
|
|
25
|
+
|
|
26
|
+
/** Request method names, used by the client's typed `call` overloads. */
|
|
27
|
+
export type HelperMethod = HelperRequest['method']
|
|
28
|
+
|
|
29
|
+
export type HelperResponse =
|
|
30
|
+
| { id: RpcId, ok: true, result?: unknown }
|
|
31
|
+
| { id: RpcId, ok: false, error: RpcErrorPayload }
|
|
32
|
+
|
|
33
|
+
export type HelperEvent =
|
|
34
|
+
| { type: 'event', kind: 'caddy.stdout', data: string }
|
|
35
|
+
| { type: 'event', kind: 'caddy.stderr', data: string }
|
|
36
|
+
| { type: 'event', kind: 'caddy.exit', code: number | null, signal: string | null }
|
|
37
|
+
|
|
38
|
+
export type HelperMessage = HelperResponse | HelperEvent
|
|
39
|
+
|
|
40
|
+
export interface ClientHandshake {
|
|
41
|
+
type: 'handshake'
|
|
42
|
+
token: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ServerHandshake {
|
|
46
|
+
type: 'handshake'
|
|
47
|
+
ok: boolean
|
|
48
|
+
pid?: number
|
|
49
|
+
reason?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type DecodedMessage =
|
|
53
|
+
| { ok: true, message: HelperMessage }
|
|
54
|
+
| { ok: false, error: string }
|
|
55
|
+
|
|
56
|
+
export function serializeMessage(message: unknown): string {
|
|
57
|
+
return `${JSON.stringify(message)}\n`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Splits a stream into complete lines, keeping the trailing partial line. */
|
|
61
|
+
export function splitLines(buffer: string, chunk: string): { lines: string[], rest: string } {
|
|
62
|
+
const parts = (buffer + chunk).split('\n')
|
|
63
|
+
const rest = parts.pop() ?? ''
|
|
64
|
+
return { lines: parts.filter(line => line.trim() !== ''), rest }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
68
|
+
return typeof value === 'object' && value !== null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function decodeMessage(line: string): DecodedMessage {
|
|
72
|
+
let parsed: unknown
|
|
73
|
+
try {
|
|
74
|
+
parsed = JSON.parse(line)
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
return { ok: false, error: `invalid JSON: ${(error as Error).message}` }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!isRecord(parsed))
|
|
81
|
+
return { ok: false, error: 'message must be an object' }
|
|
82
|
+
|
|
83
|
+
if (parsed.type === 'event') {
|
|
84
|
+
if (typeof parsed.kind !== 'string')
|
|
85
|
+
return { ok: false, error: 'event.kind must be a string' }
|
|
86
|
+
return { ok: true, message: parsed as unknown as HelperEvent }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (typeof parsed.id !== 'number')
|
|
90
|
+
return { ok: false, error: 'response.id must be a number' }
|
|
91
|
+
|
|
92
|
+
if (parsed.ok === true)
|
|
93
|
+
return { ok: true, message: { id: parsed.id, ok: true, result: parsed.result } }
|
|
94
|
+
|
|
95
|
+
if (parsed.ok === false) {
|
|
96
|
+
const error = isRecord(parsed.error) ? parsed.error : {}
|
|
97
|
+
return {
|
|
98
|
+
ok: true,
|
|
99
|
+
message: {
|
|
100
|
+
id: parsed.id,
|
|
101
|
+
ok: false,
|
|
102
|
+
error: {
|
|
103
|
+
message: typeof error.message === 'string' ? error.message : 'helper request failed',
|
|
104
|
+
code: typeof error.code === 'string' ? error.code : undefined,
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { ok: false, error: 'response.ok must be a boolean' }
|
|
111
|
+
}
|