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,332 @@
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
+ /** Admin API socket the helper talks to when installing the CA. */
43
+ caddyAdminSocket?: string
44
+ /** Shown before the password prompt so the request is not a surprise. */
45
+ reason?: { target: string, https: boolean }
46
+ }
47
+
48
+ /**
49
+ * Explains why a password is about to be requested. Without this the prompt
50
+ * appears out of nowhere in the middle of `npm run dev` and looks like something
51
+ * went wrong.
52
+ */
53
+ export function describePasswordReason(target: string, https: boolean): string[] {
54
+ const port = https ? '443' : '80'
55
+ return [
56
+ `reverse proxy needs administrator permission for ${target}`,
57
+ ` · bind port ${port}${https ? ` for https://${target}` : ''}`,
58
+ ` · add ${target} to the hosts file`,
59
+ '',
60
+ 'This is the only prompt; the rest of the dev server keeps running as you.',
61
+ 'Press Ctrl+C at the prompt to skip the proxy and keep the dev server.',
62
+ ]
63
+ }
64
+
65
+ /**
66
+ * Asks for the administrator password once, up front, on the dev terminal.
67
+ *
68
+ * The credential cache is keyed by terminal, so a later `sudo -n` only finds
69
+ * this entry when the child stays in the same session. That is why the helper
70
+ * is spawned without `detached`.
71
+ *
72
+ * Returns `denied` when the user dismissed the prompt, so the caller can keep
73
+ * the dev server running instead of treating it as a crash.
74
+ */
75
+ export async function authenticateSudo(
76
+ sudoCommand = 'sudo',
77
+ options: { target?: string, https?: boolean, quiet?: boolean } = {},
78
+ ): Promise<{ ok: boolean, denied?: boolean, message?: string }> {
79
+ if (platform() === 'win32')
80
+ return { ok: true }
81
+
82
+ if (spawnSync(sudoCommand, ['-n', 'true'], { stdio: 'ignore' }).status === 0)
83
+ return { ok: true }
84
+
85
+ // Tests drive this without a terminal; writing the notice there would print
86
+ // password instructions into the middle of the test output.
87
+ if (options.target && !options.quiet) {
88
+ for (const line of describePasswordReason(options.target, options.https ?? false))
89
+ process.stderr.write(`${line}\n`)
90
+ process.stderr.write('\n')
91
+ }
92
+
93
+ // `sudo -v` inherits the terminal, so Ctrl+C reaches this process too. Without
94
+ // a handler the default action kills the dev server, which reads as "the
95
+ // proxy crashed my dev server" when the user only meant to skip it.
96
+ let interrupted = false
97
+ const onSigint = (): void => {
98
+ interrupted = true
99
+ }
100
+ process.on('SIGINT', onSigint)
101
+
102
+ try {
103
+ const { status, signal } = await runPrompt(sudoCommand)
104
+ if (status === 0)
105
+ return { ok: true }
106
+
107
+ // `sudo` exits 1 on a wrong password and dies by the signal on Ctrl+C.
108
+ // Both are checked: a real Ctrl+C reaches this process and the child
109
+ // together, while a signal aimed at the child alone only shows up here.
110
+ const cancelled = interrupted || signal === 'SIGINT'
111
+ return {
112
+ ok: false,
113
+ denied: true,
114
+ message: cancelled
115
+ ? 'administrator permission was cancelled'
116
+ : 'administrator permission is required to edit the hosts file and bind ports 80/443',
117
+ }
118
+ }
119
+ finally {
120
+ process.off('SIGINT', onSigint)
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Runs `sudo -v` with the terminal attached.
126
+ *
127
+ * Spawned asynchronously rather than with `spawnSync`: the synchronous form
128
+ * blocks the event loop, so the SIGINT handler above never runs and the exit
129
+ * looks like a plain failure instead of a cancellation.
130
+ *
131
+ * The terminal is put back into cooked mode first. Vite turns on raw mode for
132
+ * its `h + enter` shortcuts, and in that mode Enter no longer terminates the
133
+ * line sudo is reading, so the password appears to need a second Enter.
134
+ */
135
+ function runPrompt(sudoCommand: string): Promise<{ status: number | null, signal: NodeJS.Signals | null }> {
136
+ return new Promise((resolve) => {
137
+ const restore = takeBackTerminal()
138
+ const child = spawn(sudoCommand, ['-v'], { stdio: 'inherit' })
139
+
140
+ const finish = (result: { status: number | null, signal: NodeJS.Signals | null }): void => {
141
+ restore()
142
+ resolve(result)
143
+ }
144
+
145
+ child.on('error', () => finish({ status: 1, signal: null }))
146
+ child.on('exit', (status, signal) => finish({ status, signal }))
147
+ })
148
+ }
149
+
150
+ /**
151
+ * Gives the terminal to `sudo` for the duration of the prompt.
152
+ *
153
+ * Vite keeps stdin in raw mode with a `data` listener attached, so that every
154
+ * keystroke reaches its shortcut handler. Two things break the password read:
155
+ *
156
+ * 1. Raw mode does no line editing, so Enter never terminates the line sudo is
157
+ * waiting for.
158
+ * 2. The `data` listener is resumed, so it consumes the characters before sudo
159
+ * can see them. sudo then reads nothing and reports a wrong password.
160
+ *
161
+ * Both are undone here and restored afterwards, so Vite's shortcuts keep
162
+ * working once the prompt is over.
163
+ */
164
+ function takeBackTerminal(): () => void {
165
+ const stdin = process.stdin
166
+ if (!stdin.isTTY || typeof stdin.setRawMode !== 'function')
167
+ return () => {}
168
+
169
+ const wasRaw = stdin.isRaw === true
170
+
171
+ // `pause` detaches the stream from the tty without removing Vite's listener,
172
+ // so the same handler still works when the stream is resumed later.
173
+ stdin.pause()
174
+
175
+ try {
176
+ stdin.setRawMode(false)
177
+ }
178
+ catch {
179
+ stdin.resume()
180
+ return () => {}
181
+ }
182
+
183
+ return () => {
184
+ if (wasRaw) {
185
+ try {
186
+ stdin.setRawMode(true)
187
+ }
188
+ catch {}
189
+ }
190
+ // Resume last: Vite's listener is still attached and expects a live stream.
191
+ stdin.resume()
192
+ }
193
+ }
194
+
195
+ /** The user dismissed the password prompt; this is a choice, not a failure. */
196
+ export class PrivilegeDeniedError extends Error {
197
+ constructor(message: string) {
198
+ super(message)
199
+ this.name = 'PrivilegeDeniedError'
200
+ }
201
+ }
202
+
203
+ export interface PrivilegedHelperHandle {
204
+ client: PrivilegedClient
205
+ close: () => Promise<void>
206
+ }
207
+
208
+ /**
209
+ * Starts the privileged helper and connects to it.
210
+ *
211
+ * Only this child runs as root: the Node process that owns the dev server,
212
+ * its caches and `node_modules` keeps the developer's identity. The user's
213
+ * proxy/HOME settings reach Caddy through the helper's environment instead of
214
+ * `sudo -E`.
215
+ */
216
+ export async function startPrivilegedHelper(options: LaunchOptions): Promise<PrivilegedHelperHandle> {
217
+ const {
218
+ socketPath,
219
+ token,
220
+ helperEntry,
221
+ helperArgs = [],
222
+ sudoCommand = 'sudo',
223
+ skipAuth = false,
224
+ direct = false,
225
+ env = process.env,
226
+ log,
227
+ connectTimeoutMs = 120_000,
228
+ } = options
229
+
230
+ if (!skipAuth && !direct) {
231
+ const auth = await authenticateSudo(sudoCommand, options.reason)
232
+ if (!auth.ok) {
233
+ const message = auth.message ?? 'administrator permission is required'
234
+ throw auth.denied ? new PrivilegeDeniedError(message) : new Error(message)
235
+ }
236
+ }
237
+
238
+ const forwarded = collectForwardedEnv(env)
239
+ const forwardedArgs = Object.entries(forwarded).map(([key, value]) => `${key}=${value}`)
240
+ // `helperEntry` is the runtime and `helperArgs` the script it must run, so
241
+ // the helper's own flags always come last. The owner ids travel with them
242
+ // because the helper only learns who its client is at handshake time.
243
+ const childArgv = [
244
+ ...helperArgs,
245
+ '--socket',
246
+ socketPath,
247
+ '--token',
248
+ token,
249
+ ...(typeof process.getuid === 'function' ? ['--owner-uid', String(process.getuid())] : []),
250
+ ...(typeof process.getgid === 'function' ? ['--owner-gid', String(process.getgid())] : []),
251
+ ...(options.caddyStoragePath ? ['--caddy-storage', options.caddyStoragePath] : []),
252
+ '--caddy',
253
+ options.caddyBin,
254
+ '--caddy-config',
255
+ options.caddyConfigPath,
256
+ ...(options.caddyAdminSocket ? ['--caddy-admin', options.caddyAdminSocket] : []),
257
+ ]
258
+
259
+ let child: ReturnType<typeof spawn> | undefined
260
+
261
+ if (!direct && platform() === 'win32') {
262
+ // UAC: the elevated helper is a separate process and cannot inherit stdio,
263
+ // so it reports through the named pipe instead. The user's environment is
264
+ // recovered by the helper from its own process env, which Windows keeps.
265
+ await spawnElevatedOnWindows({ file: helperEntry, args: childArgv, cwd: process.cwd() })
266
+ }
267
+ else {
268
+ const command = direct ? helperEntry : sudoCommand
269
+ const spawnArgs = direct
270
+ ? childArgv
271
+ : ['-n', 'env', ...forwardedArgs, helperEntry, ...childArgv]
272
+
273
+ // No `detached`: sudo stores its credential per session, and `setsid()`
274
+ // would make the helper look like a different login, so `sudo -n` could not
275
+ // reuse the password entered a moment earlier. The stdio pipes keep the
276
+ // helper reachable from this process.
277
+ child = spawn(command, spawnArgs, {
278
+ stdio: ['ignore', 'pipe', 'pipe'],
279
+ })
280
+
281
+ child.stdout?.on('data', (data: Buffer) => log?.(data.toString().trimEnd()))
282
+ child.stderr?.on('data', (data: Buffer) => log?.(data.toString().trimEnd()))
283
+
284
+ const exited = new Promise<number | null>(resolve => child!.once('exit', code => resolve(code)))
285
+
286
+ // Refused credentials make the helper exit immediately; report that instead
287
+ // of waiting for the full connection timeout.
288
+ const earlyExit = await Promise.race([
289
+ exited,
290
+ new Promise<undefined>(resolve => setTimeout(() => resolve(undefined), 500)),
291
+ ])
292
+
293
+ if (earlyExit !== undefined && earlyExit !== 0) {
294
+ throw new Error(
295
+ `privileged helper exited with code ${earlyExit}. `
296
+ + 'Administrator permission was refused, or sudo is not available.',
297
+ )
298
+ }
299
+ }
300
+
301
+ try {
302
+ const client = await connectClient({ socketPath, token, timeoutMs: connectTimeoutMs })
303
+ return {
304
+ client,
305
+ async close() {
306
+ await client.close().catch(() => {})
307
+ child?.unref()
308
+ },
309
+ }
310
+ }
311
+ catch (error) {
312
+ try {
313
+ child?.kill()
314
+ }
315
+ catch {}
316
+ throw error
317
+ }
318
+ }
319
+
320
+ /** Reuses a helper that is already running for this runtime directory. */
321
+ export async function connectExistingHelper(
322
+ socketPath: string,
323
+ token: string,
324
+ timeoutMs = 1500,
325
+ ): Promise<PrivilegedClient | undefined> {
326
+ try {
327
+ return await connectClient({ socketPath, token, timeoutMs })
328
+ }
329
+ catch {
330
+ return undefined
331
+ }
332
+ }
@@ -0,0 +1,74 @@
1
+ import type { Stats } from 'node:fs'
2
+ import { lstatSync } from 'node:fs'
3
+
4
+ /**
5
+ * Why a path is refused before the helper runs it.
6
+ *
7
+ * The helper runs as root while the paths come from a process running as the
8
+ * developer. Anything that process, or any other local user, can rewrite after
9
+ * the check would become root-executed code or root-loaded config, so the
10
+ * checks below run once at startup and the result is trusted for the session.
11
+ */
12
+ export interface PathRejection {
13
+ path: string
14
+ reason: string
15
+ }
16
+
17
+ export interface PathCheckOptions {
18
+ /**
19
+ * uid allowed to own the path besides root.
20
+ *
21
+ * The helper entry itself lives in the developer's `node_modules`, so the
22
+ * invoking user has to be accepted for the paths that came from there.
23
+ */
24
+ ownerUid?: number
25
+ /** Human-readable role, used in the rejection message. */
26
+ label: string
27
+ }
28
+
29
+ function describe(stats: Stats): string {
30
+ return `uid=${stats.uid} mode=${(stats.mode & 0o777).toString(8)}`
31
+ }
32
+
33
+ /**
34
+ * Rejects a path the helper must not execute or load.
35
+ *
36
+ * Symbolic links are refused outright: the check has to describe the file that
37
+ * will actually be used, and a link can be repointed after the check. Group- or
38
+ * world-writable files are refused because any local user could replace them
39
+ * between the check and the exec.
40
+ */
41
+ export function checkPrivilegedPath(
42
+ path: string,
43
+ options: PathCheckOptions,
44
+ ): PathRejection | undefined {
45
+ let stats: Stats
46
+ try {
47
+ stats = lstatSync(path)
48
+ }
49
+ catch (error) {
50
+ return { path, reason: `${options.label} is not readable: ${(error as Error).message}` }
51
+ }
52
+
53
+ if (stats.isSymbolicLink())
54
+ return { path, reason: `${options.label} is a symbolic link (${describe(stats)})` }
55
+
56
+ if (!stats.isFile() && !stats.isDirectory())
57
+ return { path, reason: `${options.label} is neither a file nor a directory` }
58
+
59
+ const allowedOwner = stats.uid === 0 || (options.ownerUid !== undefined && stats.uid === options.ownerUid)
60
+ if (!allowedOwner)
61
+ return { path, reason: `${options.label} is owned by another user (${describe(stats)})` }
62
+
63
+ // Root's own files are fine at 644/755; the point is that no one else can
64
+ // write them.
65
+ if (stats.mode & 0o022)
66
+ return { path, reason: `${options.label} is group- or world-writable (${describe(stats)})` }
67
+
68
+ return undefined
69
+ }
70
+
71
+ /** Formats a rejection for the helper log; one place keeps the wording stable. */
72
+ export function describeRejection(rejection: PathRejection): string {
73
+ return `refusing to start: ${rejection.reason} (${rejection.path})`
74
+ }
@@ -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
+ }