@bun-native/runtime 0.0.0-bootstrap.0 → 0.33.0

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/src/index.ts ADDED
@@ -0,0 +1,1032 @@
1
+ import {
2
+ filesystemRootArgument,
3
+ fileResultAnswers,
4
+ parseFilesystemRoots,
5
+ parseProcessExecutables,
6
+ processArguments,
7
+ encodeFrame,
8
+ FrameDecoder,
9
+ parseCommand,
10
+ parseEvent,
11
+ PROTOCOL_VERSION,
12
+ REQUIRED_CAPABILITIES,
13
+ ProtocolError,
14
+ type Command,
15
+ type NativeEvent,
16
+ type ErrorCode,
17
+ type Limits,
18
+ type ServicePolicy,
19
+ type Capability,
20
+ type FileAccess,
21
+ } from '@bun-native/protocol'
22
+ import { isAbsolute } from 'node:path'
23
+ import type { ReadableStreamDefaultReader } from 'node:stream/web'
24
+ import { exitDiagnostic, NativeHostExitError } from './exit-diagnostic.ts'
25
+ import { StartupTrace } from './startup-trace.ts'
26
+
27
+ export {
28
+ MAX_DIAGNOSTIC_TAIL,
29
+ NativeHostExitError,
30
+ type NativeHostExitDiagnostic,
31
+ type NativeHostExitReason,
32
+ } from './exit-diagnostic.ts'
33
+ export { installedRelease, reportHealthy, type InstalledRelease } from './installation.ts'
34
+
35
+ type WithoutId<T> = T extends { id: number } ? Omit<T, 'id'> : never
36
+ export type WindowCommand = WithoutId<
37
+ Extract<
38
+ Command,
39
+ {
40
+ type:
41
+ | 'open'
42
+ | 'batch'
43
+ | 'close'
44
+ | 'display_snapshot'
45
+ | 'clipboard_read'
46
+ | 'clipboard_write'
47
+ | 'register_global_shortcut'
48
+ | 'unregister_global_shortcut'
49
+ | 'post_notification'
50
+ | 'withdraw_notification'
51
+ | 'open_file_grant'
52
+ | 'close_file_grant'
53
+ | 'file_request'
54
+ | 'spawn_process'
55
+ | 'write_process'
56
+ | 'close_process_input'
57
+ | 'resize_process'
58
+ | 'signal_process'
59
+ | 'acknowledge_process_output'
60
+ | 'release_process'
61
+ | 'show_file_dialog'
62
+ | 'close_file_dialog'
63
+ | 'show_message_dialog'
64
+ | 'close_message_dialog'
65
+ | 'show_context_menu'
66
+ | 'close_context_menu'
67
+ | 'register_drop_target'
68
+ | 'unregister_drop_target'
69
+ | 'resolve_dropped_item'
70
+ | 'set_app_menu'
71
+ }
72
+ >
73
+ >
74
+ export type WindowEvent = Extract<NativeEvent, { sequence: number; window_id: number }>
75
+ /** Sequenced events owned by the application rather than one window. */
76
+ export type ApplicationEvent = Extract<NativeEvent, { type: 'app_menu_item' }>
77
+ export type HostEvent = WindowEvent | ApplicationEvent
78
+ export type HostResponse = Extract<
79
+ NativeEvent,
80
+ {
81
+ type:
82
+ | 'ack'
83
+ | 'opened'
84
+ | 'applied'
85
+ | 'display_snapshot'
86
+ | 'clipboard_text'
87
+ | 'clipboard_written'
88
+ | 'global_shortcut_registered'
89
+ | 'global_shortcut_unregistered'
90
+ | 'notification_posted'
91
+ | 'notification_withdrawn'
92
+ | 'file_grant_opened'
93
+ | 'file_grant_closed'
94
+ | 'file_result'
95
+ | 'process_spawned'
96
+ | 'process_updated'
97
+ | 'process_released'
98
+ | 'file_dialog_opened'
99
+ | 'file_dialog_closed'
100
+ | 'message_dialog_opened'
101
+ | 'message_dialog_closed'
102
+ | 'context_menu_opened'
103
+ | 'context_menu_closed'
104
+ | 'drop_target_registered'
105
+ | 'drop_target_unregistered'
106
+ | 'dropped_item_resolved'
107
+ | 'app_menu_set'
108
+ }
109
+ >
110
+
111
+ export interface ProcessLimitPermissions {
112
+ /** CPU seconds before the child is stopped (SIGXCPU on POSIX). */
113
+ readonly cpuSeconds?: number
114
+ /** Address space (Linux) or committed memory (Windows) in bytes. */
115
+ readonly memoryBytes?: number
116
+ /** Open descriptors (POSIX only). */
117
+ readonly openFiles?: number
118
+ }
119
+
120
+ export interface ServicePermissions {
121
+ /** Default deny. Fixed for the native process lifetime; no wire request can grant it. */
122
+ readonly windowDisplay?: 'allow' | 'deny'
123
+ readonly clipboardRead?: 'allow' | 'deny'
124
+ readonly clipboardWrite?: 'allow' | 'deny'
125
+ /** OS-wide key grabs that deliver while another application is focused. */
126
+ readonly globalShortcuts?: 'allow' | 'deny'
127
+ /** Desktop notifications posted by an owned window. */
128
+ readonly notifications?: 'allow' | 'deny'
129
+ /** Owned drop targets that receive external file drops as untrusted names. */
130
+ readonly dragDrop?: 'allow' | 'deny'
131
+ /**
132
+ * Directories an owned window may open as scoped file grants. Each `path`
133
+ * is absolute and normalized; `access` caps every grant on that root.
134
+ */
135
+ readonly filesystemRoots?: readonly Readonly<{ id: string; path: string; access: FileAccess }>[]
136
+ /**
137
+ * Executables an owned window may start as child processes, by ID. Each
138
+ * `executable` is an absolute normalized path; nothing else can be started.
139
+ * Optional `limits` apply to every child started from that entry; a
140
+ * platform that cannot enforce one refuses the spawn with
141
+ * `service_unavailable` (macOS has no memory limit, Windows no open-files
142
+ * limit).
143
+ */
144
+ readonly processes?: readonly Readonly<{
145
+ id: string
146
+ executable: string
147
+ limits?: Readonly<ProcessLimitPermissions>
148
+ }>[]
149
+ /** OS file open/save/folder dialogs owned by a window. Paths are data, never grants. */
150
+ readonly fileDialogs?: 'allow' | 'deny'
151
+ /** The application menu bar (macOS). Selections are reported as item IDs. */
152
+ readonly appMenu?: 'allow' | 'deny'
153
+ /** OS message dialogs (Windows task dialogs, macOS alert sheets) instead of the in-window overlay. */
154
+ readonly taskDialogs?: 'allow' | 'deny'
155
+ }
156
+
157
+ export class NativeCommandError extends Error {
158
+ override readonly name = 'NativeCommandError'
159
+ constructor(
160
+ readonly code: ErrorCode,
161
+ readonly requestId: number,
162
+ message: string
163
+ ) {
164
+ super('Native ' + code + ': ' + message)
165
+ }
166
+ }
167
+
168
+ export interface HostOptions {
169
+ /** Explicit prebuilt executable; starting an app never invokes Cargo. */
170
+ executable: string
171
+ /** Optional native process arguments, passed without a shell. */
172
+ args?: readonly string[]
173
+ requestTimeoutMs?: number
174
+ permissions?: ServicePermissions
175
+ }
176
+
177
+ function launchPolicy(value: unknown = {}): Readonly<ServicePolicy> {
178
+ if (!value || typeof value !== 'object' || Array.isArray(value))
179
+ throw new TypeError('Native permissions must be an object')
180
+ if (
181
+ Object.keys(value).some(
182
+ (key) =>
183
+ ![
184
+ 'windowDisplay',
185
+ 'clipboardRead',
186
+ 'clipboardWrite',
187
+ 'globalShortcuts',
188
+ 'notifications',
189
+ 'dragDrop',
190
+ 'filesystemRoots',
191
+ 'processes',
192
+ 'fileDialogs',
193
+ 'appMenu',
194
+ 'taskDialogs',
195
+ ].includes(key)
196
+ )
197
+ )
198
+ throw new TypeError('Unknown native service permission')
199
+ const grant = (key: keyof ServicePermissions) => {
200
+ const permission = Object.hasOwn(value, key) ? (value as Record<string, unknown>)[key] : 'deny'
201
+ if (permission !== 'allow' && permission !== 'deny')
202
+ throw new TypeError(key + ' permission must be allow or deny')
203
+ return permission === 'allow'
204
+ }
205
+ return Object.freeze({
206
+ window_display: grant('windowDisplay'),
207
+ clipboard_read: grant('clipboardRead'),
208
+ clipboard_write: grant('clipboardWrite'),
209
+ global_shortcuts: grant('globalShortcuts'),
210
+ notifications: grant('notifications'),
211
+ drag_drop: grant('dragDrop'),
212
+ filesystem_roots: filesystemRoots(value),
213
+ processes: processes(value),
214
+ file_dialogs: grant('fileDialogs'),
215
+ app_menu: grant('appMenu'),
216
+ task_dialogs: grant('taskDialogs'),
217
+ })
218
+ }
219
+
220
+ function processes(value: object): ServicePolicy['processes'] {
221
+ const entries = Object.hasOwn(value, 'processes')
222
+ ? (value as Record<string, unknown>)['processes']
223
+ : []
224
+ try {
225
+ return Object.freeze(
226
+ parseProcessExecutables(wireProcesses(entries)).map((entry) =>
227
+ Object.freeze(entry.limits ? { ...entry, limits: Object.freeze(entry.limits) } : entry)
228
+ )
229
+ ) as ServicePolicy['processes']
230
+ } catch (error) {
231
+ throw new TypeError(
232
+ 'processes must list at most 8 unique { id, executable, limits? } entries: ' +
233
+ (error as Error).message
234
+ )
235
+ }
236
+ }
237
+
238
+ const LIMIT_WIRE_NAMES = {
239
+ cpuSeconds: 'cpu_seconds',
240
+ memoryBytes: 'memory_bytes',
241
+ openFiles: 'open_files',
242
+ } as const
243
+
244
+ /** Public camelCase limits become the wire's snake_case; anything else is left for the parser to reject. */
245
+ function wireProcesses(entries: unknown): unknown {
246
+ if (!Array.isArray(entries)) return entries
247
+ return entries.map((entry: unknown) => {
248
+ if (!entry || typeof entry !== 'object' || !Object.hasOwn(entry, 'limits')) return entry
249
+ const { limits, ...rest } = entry as { limits: unknown }
250
+ if (!limits || typeof limits !== 'object' || Array.isArray(limits)) return { ...rest, limits }
251
+ return {
252
+ ...rest,
253
+ limits: Object.fromEntries(
254
+ Object.entries(limits).map(([name, value]) => [
255
+ Object.hasOwn(LIMIT_WIRE_NAMES, name)
256
+ ? LIMIT_WIRE_NAMES[name as keyof typeof LIMIT_WIRE_NAMES]
257
+ : name,
258
+ value,
259
+ ])
260
+ ),
261
+ }
262
+ })
263
+ }
264
+
265
+ function filesystemRoots(value: object): ServicePolicy['filesystem_roots'] {
266
+ const roots = Object.hasOwn(value, 'filesystemRoots')
267
+ ? (value as Record<string, unknown>)['filesystemRoots']
268
+ : []
269
+ try {
270
+ return Object.freeze(
271
+ parseFilesystemRoots(roots).map((root) => Object.freeze(root))
272
+ ) as ServicePolicy['filesystem_roots']
273
+ } catch (error) {
274
+ throw new TypeError(
275
+ 'filesystemRoots must list at most 8 unique { id, path, access } roots: ' +
276
+ (error as Error).message
277
+ )
278
+ }
279
+ }
280
+
281
+ function samePolicy(left: Readonly<ServicePolicy>, right: Readonly<ServicePolicy>): boolean {
282
+ return (
283
+ left.window_display === right.window_display &&
284
+ left.clipboard_read === right.clipboard_read &&
285
+ left.clipboard_write === right.clipboard_write &&
286
+ left.global_shortcuts === right.global_shortcuts &&
287
+ left.notifications === right.notifications &&
288
+ (left.file_dialogs ?? false) === (right.file_dialogs ?? false) &&
289
+ (left.app_menu ?? false) === (right.app_menu ?? false) &&
290
+ (left.task_dialogs ?? false) === (right.task_dialogs ?? false) &&
291
+ left.drag_drop === right.drag_drop &&
292
+ left.filesystem_roots.length === right.filesystem_roots.length &&
293
+ left.filesystem_roots.every(
294
+ (root, index) =>
295
+ root.id === right.filesystem_roots[index]!.id &&
296
+ root.path === right.filesystem_roots[index]!.path &&
297
+ root.access === right.filesystem_roots[index]!.access
298
+ ) &&
299
+ left.processes.length === right.processes.length &&
300
+ left.processes.every(
301
+ (entry, index) =>
302
+ entry.id === right.processes[index]!.id &&
303
+ entry.executable === right.processes[index]!.executable &&
304
+ entry.limits?.cpu_seconds === right.processes[index]!.limits?.cpu_seconds &&
305
+ entry.limits?.memory_bytes === right.processes[index]!.limits?.memory_bytes &&
306
+ entry.limits?.open_files === right.processes[index]!.limits?.open_files
307
+ )
308
+ )
309
+ }
310
+
311
+ type Pending = {
312
+ command: Command
313
+ resolve: (response: HostResponse) => void
314
+ reject: (error: Error) => void
315
+ timer: ReturnType<typeof setTimeout>
316
+ }
317
+
318
+ /** Diagnostic only: when the serialized stdin write and flush of one request settled. */
319
+ type RequestWrite = { readonly queuedAt: number; writtenAt?: number; flushedAt?: number }
320
+
321
+ function describeWrite(write: RequestWrite): string {
322
+ const at = (time: number) => '+' + Math.round(time - write.queuedAt) + 'ms'
323
+ const state =
324
+ write.flushedAt !== undefined ? 'flushed' : write.writtenAt !== undefined ? 'written' : 'queued'
325
+ return (
326
+ `write ${state}: queued +0ms` +
327
+ (write.writtenAt === undefined ? '' : ', written ' + at(write.writtenAt)) +
328
+ (write.flushedAt === undefined ? '' : ', flushed ' + at(write.flushedAt))
329
+ )
330
+ }
331
+
332
+ type WaitingReader = {
333
+ resolve: (value: IteratorResult<HostEvent>) => void
334
+ reject: (error: Error) => void
335
+ }
336
+
337
+ const MAX_PENDING_COMMANDS = 128
338
+ const MAX_QUEUED_EVENTS = 128
339
+
340
+ /**
341
+ * Owns one native process and its bounded, versioned channel. Private transport;
342
+ * framework-specific reconciliation belongs above this shared connection.
343
+ */
344
+ export class NativeHost implements AsyncIterable<HostEvent> {
345
+ readonly exited: Promise<number>
346
+ private readonly process: Bun.Subprocess<'pipe', 'pipe', 'pipe'>
347
+ private readonly stdout: ReadableStreamDefaultReader<Uint8Array>
348
+ private readonly stderr: ReadableStreamDefaultReader<Uint8Array>
349
+ private readonly timeout: number
350
+ private readonly pending = new Map<number, Pending>()
351
+ private readonly events: HostEvent[] = []
352
+ private readonly finished: Promise<void>
353
+ private waiting: WaitingReader | undefined
354
+ private failure: Error | undefined
355
+ private phase: 'starting' | 'running' | 'stopping' | 'closed' = 'starting'
356
+ private ready = false
357
+ private negotiatedLimits: Readonly<Limits> | undefined
358
+ private negotiatedServicePolicy: Readonly<ServicePolicy> | undefined
359
+ private negotiatedCapabilities: readonly Capability[] | undefined
360
+ private sequence = 0
361
+ private nextId = 1
362
+ private writeTail: Promise<void> = Promise.resolve()
363
+ private stderrTail = ''
364
+ private stderrClipped = false
365
+ private stderrPanic: string | null = null
366
+ private iteratorTaken = false
367
+ private readonly startupTrace = StartupTrace.fromEnvironment()
368
+
369
+ private constructor(
370
+ options: HostOptions,
371
+ private readonly requestedPolicy: Readonly<ServicePolicy>
372
+ ) {
373
+ this.timeout = options.requestTimeoutMs ?? 5_000
374
+ this.process = Bun.spawn({
375
+ cmd: [
376
+ options.executable,
377
+ ...(options.args ?? []),
378
+ ...(requestedPolicy.window_display ? ['--bun-native-allow-window-display'] : []),
379
+ ...(requestedPolicy.clipboard_read ? ['--bun-native-allow-clipboard-read'] : []),
380
+ ...(requestedPolicy.clipboard_write ? ['--bun-native-allow-clipboard-write'] : []),
381
+ ...(requestedPolicy.global_shortcuts ? ['--bun-native-allow-global-shortcuts'] : []),
382
+ ...(requestedPolicy.notifications ? ['--bun-native-allow-notifications'] : []),
383
+ ...(requestedPolicy.drag_drop ? ['--bun-native-allow-drag-drop'] : []),
384
+ ...requestedPolicy.filesystem_roots.map(filesystemRootArgument),
385
+ ...requestedPolicy.processes.flatMap(processArguments),
386
+ ...(requestedPolicy.file_dialogs ? ['--bun-native-allow-file-dialogs'] : []),
387
+ ...(requestedPolicy.app_menu ? ['--bun-native-allow-app-menu'] : []),
388
+ ...(requestedPolicy.task_dialogs ? ['--bun-native-allow-task-dialogs'] : []),
389
+ ],
390
+ stdin: 'pipe',
391
+ stdout: 'pipe',
392
+ stderr: 'pipe',
393
+ // A terminal's Ctrl-C signals its whole foreground process group. The
394
+ // application owns the host's lifetime and ends it through `shutdown`
395
+ // (or closed stdin), so the host runs in its own session on POSIX.
396
+ detached: process.platform !== 'win32',
397
+ })
398
+ this.stdout = this.process.stdout.getReader()
399
+ this.stderr = this.process.stderr.getReader()
400
+ this.exited = this.process.exited
401
+ this.finished = this.watch()
402
+ }
403
+
404
+ static async launch(options: HostOptions): Promise<NativeHost> {
405
+ if (!isAbsolute(options.executable)) throw new Error('Native host path must be absolute')
406
+ const timeout = options.requestTimeoutMs ?? 5_000
407
+ if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 60_000)
408
+ throw new Error('requestTimeoutMs must be an integer between 1 and 60000')
409
+ const policy = launchPolicy(options.permissions)
410
+ if (
411
+ options.args?.some(
412
+ (argument) => typeof argument !== 'string' || argument.startsWith('--bun-native-')
413
+ )
414
+ )
415
+ throw new TypeError(
416
+ 'Native service arguments are reserved for the explicit permissions option'
417
+ )
418
+ const host = new NativeHost(options, policy)
419
+ try {
420
+ await host.request({ type: 'hello', protocol: PROTOCOL_VERSION })
421
+ if (!host.ready) throw new ProtocolError('Host acknowledged hello before protocol readiness')
422
+ host.phase = 'running'
423
+ return host
424
+ } catch (error) {
425
+ host.fail(error)
426
+ await host.finished
427
+ throw host.failure
428
+ }
429
+ }
430
+
431
+ get limits(): Readonly<Limits> {
432
+ if (!this.negotiatedLimits) throw new ProtocolError('Native limits are not negotiated')
433
+ return this.negotiatedLimits
434
+ }
435
+
436
+ get servicePolicy(): Readonly<ServicePolicy> {
437
+ if (!this.negotiatedServicePolicy)
438
+ throw new ProtocolError('Native service policy is not negotiated')
439
+ return this.negotiatedServicePolicy
440
+ }
441
+
442
+ get capabilities(): readonly Capability[] {
443
+ if (!this.negotiatedCapabilities)
444
+ throw new ProtocolError('Native capabilities are not negotiated')
445
+ return this.negotiatedCapabilities
446
+ }
447
+
448
+ /** Resolves after the native UI thread acknowledges the operation. */
449
+ send(
450
+ command: Extract<WindowCommand, { type: 'open' }>
451
+ ): Promise<Extract<HostResponse, { type: 'opened' }>>
452
+ send(
453
+ command: Extract<WindowCommand, { type: 'batch' }>
454
+ ): Promise<Extract<HostResponse, { type: 'applied' }>>
455
+ send(
456
+ command: Extract<WindowCommand, { type: 'close' }>
457
+ ): Promise<Extract<HostResponse, { type: 'ack' }>>
458
+ send(
459
+ command: Extract<WindowCommand, { type: 'display_snapshot' }>
460
+ ): Promise<Extract<HostResponse, { type: 'display_snapshot' }>>
461
+ send(
462
+ command: Extract<WindowCommand, { type: 'clipboard_read' }>
463
+ ): Promise<Extract<HostResponse, { type: 'clipboard_text' }>>
464
+ send(
465
+ command: Extract<WindowCommand, { type: 'clipboard_write' }>
466
+ ): Promise<Extract<HostResponse, { type: 'clipboard_written' }>>
467
+ send(
468
+ command: Extract<WindowCommand, { type: 'register_global_shortcut' }>
469
+ ): Promise<Extract<HostResponse, { type: 'global_shortcut_registered' }>>
470
+ send(
471
+ command: Extract<WindowCommand, { type: 'unregister_global_shortcut' }>
472
+ ): Promise<Extract<HostResponse, { type: 'global_shortcut_unregistered' }>>
473
+ send(
474
+ command: Extract<WindowCommand, { type: 'post_notification' }>
475
+ ): Promise<Extract<HostResponse, { type: 'notification_posted' }>>
476
+ send(
477
+ command: Extract<WindowCommand, { type: 'withdraw_notification' }>
478
+ ): Promise<Extract<HostResponse, { type: 'notification_withdrawn' }>>
479
+ send(
480
+ command: Extract<WindowCommand, { type: 'open_file_grant' }>
481
+ ): Promise<Extract<HostResponse, { type: 'file_grant_opened' }>>
482
+ send(
483
+ command: Extract<WindowCommand, { type: 'close_file_grant' }>
484
+ ): Promise<Extract<HostResponse, { type: 'file_grant_closed' }>>
485
+ send(
486
+ command: Extract<WindowCommand, { type: 'file_request' }>
487
+ ): Promise<Extract<HostResponse, { type: 'file_result' }>>
488
+ send(
489
+ command: Extract<WindowCommand, { type: 'spawn_process' }>
490
+ ): Promise<Extract<HostResponse, { type: 'process_spawned' }>>
491
+ send(
492
+ command: Extract<WindowCommand, { type: 'release_process' }>
493
+ ): Promise<Extract<HostResponse, { type: 'process_released' }>>
494
+ send(
495
+ command: Extract<
496
+ WindowCommand,
497
+ {
498
+ type:
499
+ | 'write_process'
500
+ | 'close_process_input'
501
+ | 'resize_process'
502
+ | 'signal_process'
503
+ | 'acknowledge_process_output'
504
+ }
505
+ >
506
+ ): Promise<Extract<HostResponse, { type: 'process_updated' }>>
507
+ send(
508
+ command: Extract<WindowCommand, { type: 'show_file_dialog' }>
509
+ ): Promise<Extract<HostResponse, { type: 'file_dialog_opened' }>>
510
+ send(
511
+ command: Extract<WindowCommand, { type: 'close_file_dialog' }>
512
+ ): Promise<Extract<HostResponse, { type: 'file_dialog_closed' }>>
513
+ send(
514
+ command: Extract<WindowCommand, { type: 'show_message_dialog' }>
515
+ ): Promise<Extract<HostResponse, { type: 'message_dialog_opened' }>>
516
+ send(
517
+ command: Extract<WindowCommand, { type: 'close_message_dialog' }>
518
+ ): Promise<Extract<HostResponse, { type: 'message_dialog_closed' }>>
519
+ send(
520
+ command: Extract<WindowCommand, { type: 'show_context_menu' }>
521
+ ): Promise<Extract<HostResponse, { type: 'context_menu_opened' }>>
522
+ send(
523
+ command: Extract<WindowCommand, { type: 'close_context_menu' }>
524
+ ): Promise<Extract<HostResponse, { type: 'context_menu_closed' }>>
525
+ send(
526
+ command: Extract<WindowCommand, { type: 'register_drop_target' }>
527
+ ): Promise<Extract<HostResponse, { type: 'drop_target_registered' }>>
528
+ send(
529
+ command: Extract<WindowCommand, { type: 'unregister_drop_target' }>
530
+ ): Promise<Extract<HostResponse, { type: 'drop_target_unregistered' }>>
531
+ send(
532
+ command: Extract<WindowCommand, { type: 'resolve_dropped_item' }>
533
+ ): Promise<Extract<HostResponse, { type: 'dropped_item_resolved' }>>
534
+ send(
535
+ command: Extract<WindowCommand, { type: 'set_app_menu' }>
536
+ ): Promise<Extract<HostResponse, { type: 'app_menu_set' }>>
537
+ send(command: WindowCommand): Promise<HostResponse>
538
+ send(command: WindowCommand): Promise<HostResponse> {
539
+ if (this.phase !== 'running') return Promise.reject(this.closedError())
540
+ return this.request(command)
541
+ }
542
+
543
+ async shutdown(): Promise<void> {
544
+ if (this.phase === 'closed' || this.phase === 'stopping') {
545
+ await this.finished
546
+ if (this.failure) throw this.failure
547
+ return
548
+ }
549
+ this.phase = 'stopping'
550
+ try {
551
+ await this.request({ type: 'shutdown' })
552
+ this.process.stdin.end()
553
+ const deadline = setTimeout(
554
+ () => this.fail(new Error('Native host did not exit')),
555
+ this.timeout
556
+ )
557
+ try {
558
+ await this.finished
559
+ } finally {
560
+ clearTimeout(deadline)
561
+ }
562
+ if (this.failure) throw this.failure
563
+ } catch (error) {
564
+ this.fail(error)
565
+ await this.finished
566
+ throw this.failure
567
+ }
568
+ }
569
+
570
+ [Symbol.asyncIterator](): AsyncIterator<HostEvent> {
571
+ if (this.iteratorTaken) throw new Error('Native events have exactly one consumer')
572
+ this.iteratorTaken = true
573
+ return {
574
+ next: () => {
575
+ if (this.failure) return Promise.reject(this.failure)
576
+ const event = this.events.shift()
577
+ if (event) return Promise.resolve({ done: false, value: event })
578
+ if (this.phase === 'closed') return Promise.resolve({ done: true, value: undefined })
579
+ if (this.waiting)
580
+ return Promise.reject(new Error('Concurrent event reads are not supported'))
581
+ return new Promise<IteratorResult<HostEvent>>((resolve, reject) => {
582
+ this.waiting = { resolve, reject }
583
+ })
584
+ },
585
+ return: async () => {
586
+ await this.shutdown()
587
+ return { done: true, value: undefined }
588
+ },
589
+ }
590
+ }
591
+
592
+ private request(input: WithoutId<Command>): Promise<HostResponse> {
593
+ if (this.phase === 'closed') return Promise.reject(this.closedError())
594
+ if (this.pending.size >= MAX_PENDING_COMMANDS)
595
+ return Promise.reject(new Error('Native command limit exceeded'))
596
+ let command: Command
597
+ let frame: Uint8Array
598
+ try {
599
+ command = parseCommand({ ...input, id: this.nextId++ })
600
+ frame = encodeFrame(command)
601
+ if (this.negotiatedLimits) {
602
+ if (frame.byteLength - 1 > this.negotiatedLimits.max_frame_bytes)
603
+ throw new ProtocolError('Command exceeds the negotiated frame limit')
604
+ if (
605
+ command.type === 'batch' &&
606
+ command.operations.length > this.negotiatedLimits.max_operations
607
+ )
608
+ throw new ProtocolError('Command exceeds the negotiated batch limit')
609
+ if (
610
+ command.type === 'clipboard_write' &&
611
+ Buffer.byteLength(command.text, 'utf8') > this.negotiatedLimits.max_clipboard_bytes
612
+ )
613
+ throw new ProtocolError('Clipboard write exceeds the negotiated text limit')
614
+ const chunk = this.negotiatedLimits.max_file_chunk_bytes
615
+ if (
616
+ command.type === 'file_request' &&
617
+ ((command.operation.kind === 'read' && command.operation.length > chunk) ||
618
+ (command.operation.kind === 'write' &&
619
+ Buffer.from(command.operation.data, 'base64').byteLength > chunk))
620
+ )
621
+ throw new ProtocolError('File request exceeds the negotiated chunk limit')
622
+ }
623
+ } catch (error) {
624
+ return Promise.reject(error)
625
+ }
626
+ const write: RequestWrite = { queuedAt: performance.now() }
627
+ const result = new Promise<HostResponse>((resolve, reject) => {
628
+ const timer = setTimeout(
629
+ () =>
630
+ this.fail(
631
+ new Error(
632
+ `Native ${command.type} request ${command.id} timed out (${describeWrite(write)})`
633
+ )
634
+ ),
635
+ this.timeout
636
+ )
637
+ this.pending.set(command.id, {
638
+ command,
639
+ resolve,
640
+ reject,
641
+ timer,
642
+ })
643
+ })
644
+ this.startupTrace?.request('request_queued', command, this.process.pid)
645
+ // FileSink buffering is bounded by the pending-command cap and frame limit.
646
+ this.writeTail = this.writeTail
647
+ .then(async () => {
648
+ if (this.phase === 'closed') return
649
+ this.startupTrace?.request('stdin_write_enter', command, this.process.pid)
650
+ this.process.stdin.write(frame)
651
+ write.writtenAt = performance.now()
652
+ this.startupTrace?.request('stdin_write_returned', command, this.process.pid)
653
+ this.startupTrace?.request('stdin_flush_enter', command, this.process.pid)
654
+ await this.process.stdin.flush()
655
+ write.flushedAt = performance.now()
656
+ this.startupTrace?.request('stdin_flushed', command, this.process.pid)
657
+ })
658
+ .catch((error: unknown) => this.fail(error))
659
+ return result
660
+ }
661
+
662
+ private async readStdout(): Promise<void> {
663
+ const decoder = new FrameDecoder()
664
+ try {
665
+ for (;;) {
666
+ const { done, value } = await this.stdout.read()
667
+ if (done) break
668
+ for (const raw of decoder.push(value)) this.receive(parseEvent(raw))
669
+ }
670
+ decoder.finish()
671
+ if (
672
+ this.phase !== 'stopping' &&
673
+ this.phase !== 'closed' &&
674
+ !(await this.exitsWithinTimeout())
675
+ )
676
+ throw new ProtocolError('Native output closed before shutdown')
677
+ } finally {
678
+ this.stdout.releaseLock()
679
+ }
680
+ }
681
+
682
+ private async readStderr(): Promise<void> {
683
+ const decoder = new TextDecoder()
684
+ try {
685
+ for (;;) {
686
+ const { done, value } = await this.stderr.read()
687
+ if (done) break
688
+ this.appendStderr(decoder.decode(value, { stream: true }))
689
+ }
690
+ this.appendStderr(decoder.decode())
691
+ } finally {
692
+ this.stderr.releaseLock()
693
+ }
694
+ }
695
+
696
+ private appendStderr(text: string): void {
697
+ const tail = this.stderrTail + text
698
+ // Keep the first complete panic header and message line: a long backtrace
699
+ // can push them out of the bounded tail before the host exits.
700
+ if (this.stderrPanic === null) {
701
+ const panic = /(^|\n)([^\n]*panicked at [^\n]*\n[^\n]*)\n/.exec(tail)
702
+ if (panic && (panic[1] || !this.stderrClipped)) this.stderrPanic = panic[2]!
703
+ }
704
+ if (tail.length > 4_096) this.stderrClipped = true
705
+ this.stderrTail = tail.slice(-4_096)
706
+ }
707
+
708
+ /**
709
+ * Unexpected EOF usually precedes the exit status of a crashed host. Wait for
710
+ * that status, bounded, so the failure can carry its exit diagnostic.
711
+ */
712
+ private async exitsWithinTimeout(): Promise<boolean> {
713
+ let timer: ReturnType<typeof setTimeout> | undefined
714
+ try {
715
+ return await Promise.race([
716
+ this.exited.then(() => true),
717
+ new Promise<false>((resolve) => {
718
+ timer = setTimeout(() => resolve(false), this.timeout)
719
+ }),
720
+ ])
721
+ } finally {
722
+ clearTimeout(timer)
723
+ }
724
+ }
725
+
726
+ private exitError(status: number): NativeHostExitError {
727
+ const phase = this.phase === 'closed' ? 'running' : this.phase
728
+ return new NativeHostExitError(
729
+ exitDiagnostic({
730
+ exitCode: this.process.exitCode,
731
+ signal: this.process.signalCode === null ? null : String(this.process.signalCode),
732
+ phase,
733
+ pendingRequests: this.pending.size,
734
+ stderr: this.stderrTail,
735
+ stderrClipped: this.stderrClipped,
736
+ panicReport: this.stderrPanic,
737
+ environment: process.env,
738
+ }),
739
+ status
740
+ )
741
+ }
742
+
743
+ private cancelOutput(): void {
744
+ // The host can exit while another process still holds inherited pipe ends.
745
+ // Cancel our reads directly; killing the already-exited host cannot close them.
746
+ void this.stdout.cancel().catch(() => {})
747
+ void this.stderr.cancel().catch(() => {})
748
+ }
749
+
750
+ private async watch(): Promise<void> {
751
+ const streams = [this.readStdout(), this.readStderr()]
752
+ let drainDeadline: ReturnType<typeof setTimeout> | undefined
753
+ const exit = this.exited.then((code) => {
754
+ // Allow buffered acknowledgements/diagnostics to drain, but bound EOF wait.
755
+ drainDeadline = setTimeout(
756
+ () => this.fail(new Error('Native output remained open after host exit')),
757
+ this.timeout
758
+ )
759
+ return code
760
+ })
761
+ try {
762
+ const [code] = await Promise.all([exit, ...streams])
763
+ if (code !== 0 || this.phase !== 'stopping' || this.pending.size) throw this.exitError(code)
764
+ this.phase = 'closed'
765
+ this.waiting?.resolve({ done: true, value: undefined })
766
+ this.waiting = undefined
767
+ } catch (error) {
768
+ this.fail(error)
769
+ await this.exited
770
+ } finally {
771
+ clearTimeout(drainDeadline)
772
+ this.cancelOutput()
773
+ await Promise.allSettled(streams)
774
+ this.startupTrace?.finished(this.process.exitCode, this.stderrTail, this.process.pid)
775
+ }
776
+ }
777
+
778
+ private receive(event: NativeEvent): void {
779
+ if (this.phase === 'closed') return
780
+ this.startupTrace?.response(event, this.process.pid)
781
+ if (
782
+ event.type === 'clipboard_text' &&
783
+ event.result.kind === 'text' &&
784
+ this.negotiatedLimits &&
785
+ Buffer.byteLength(event.result.text, 'utf8') > this.negotiatedLimits.max_clipboard_bytes
786
+ )
787
+ throw new ProtocolError('Clipboard response exceeds the negotiated text limit')
788
+ if (
789
+ event.type === 'file_result' &&
790
+ event.result.kind === 'read' &&
791
+ this.negotiatedLimits &&
792
+ Buffer.from(event.result.data, 'base64').byteLength >
793
+ this.negotiatedLimits.max_file_chunk_bytes
794
+ )
795
+ throw new ProtocolError('File read exceeds the negotiated chunk limit')
796
+ if (event.type === 'ready') {
797
+ if (this.ready || this.phase !== 'starting') throw new ProtocolError('Unexpected ready event')
798
+ if (REQUIRED_CAPABILITIES.some((capability) => !event.capabilities.includes(capability)))
799
+ throw new ProtocolError(
800
+ 'Native host lacks required protocol capabilities: ' + REQUIRED_CAPABILITIES.join(', ')
801
+ )
802
+ this.negotiatedLimits = Object.freeze(event.limits)
803
+ if (!samePolicy(event.service_policy, this.requestedPolicy))
804
+ throw new ProtocolError(
805
+ 'Native service policy does not match the explicit launch permissions'
806
+ )
807
+ this.negotiatedServicePolicy = this.requestedPolicy
808
+ this.negotiatedCapabilities = Object.freeze(event.capabilities)
809
+ this.ready = true
810
+ return
811
+ }
812
+ if (
813
+ event.type === 'ack' ||
814
+ event.type === 'opened' ||
815
+ event.type === 'applied' ||
816
+ event.type === 'display_snapshot' ||
817
+ event.type === 'clipboard_text' ||
818
+ event.type === 'clipboard_written' ||
819
+ event.type === 'global_shortcut_registered' ||
820
+ event.type === 'global_shortcut_unregistered' ||
821
+ event.type === 'notification_posted' ||
822
+ event.type === 'notification_withdrawn' ||
823
+ event.type === 'file_grant_opened' ||
824
+ event.type === 'file_grant_closed' ||
825
+ event.type === 'file_result' ||
826
+ event.type === 'process_spawned' ||
827
+ event.type === 'process_updated' ||
828
+ event.type === 'process_released' ||
829
+ event.type === 'file_dialog_opened' ||
830
+ event.type === 'file_dialog_closed' ||
831
+ event.type === 'message_dialog_opened' ||
832
+ event.type === 'message_dialog_closed' ||
833
+ event.type === 'context_menu_opened' ||
834
+ event.type === 'context_menu_closed' ||
835
+ event.type === 'drop_target_registered' ||
836
+ event.type === 'drop_target_unregistered' ||
837
+ event.type === 'dropped_item_resolved' ||
838
+ event.type === 'app_menu_set' ||
839
+ event.type === 'error'
840
+ ) {
841
+ if (event.type === 'error' && event.id === undefined)
842
+ throw new ProtocolError(`Native ${event.code}: ${event.message}`)
843
+ const requestId = event.id
844
+ const request = requestId === undefined ? undefined : this.pending.get(requestId)
845
+ if (!request || requestId === undefined) throw new ProtocolError('Unknown native response id')
846
+ clearTimeout(request.timer)
847
+ this.pending.delete(requestId)
848
+ if (event.type === 'error')
849
+ request.reject(new NativeCommandError(event.code, requestId, event.message))
850
+ else if (!this.matchesResponse(request.command, event)) {
851
+ const error = new ProtocolError(
852
+ 'Native response does not match its request, owner or revision'
853
+ )
854
+ request.reject(error)
855
+ throw error
856
+ } else request.resolve(event)
857
+ return
858
+ }
859
+ if (!this.ready) throw new ProtocolError('Native input arrived before protocol readiness')
860
+ if (event.sequence !== this.sequence + 1)
861
+ throw new ProtocolError('Native event sequence is out of order')
862
+ this.sequence = event.sequence
863
+ if (this.waiting) {
864
+ this.waiting.resolve({ done: false, value: event })
865
+ this.waiting = undefined
866
+ } else {
867
+ if (this.events.length >= MAX_QUEUED_EVENTS)
868
+ throw new ProtocolError('Native event limit exceeded')
869
+ this.events.push(event)
870
+ }
871
+ }
872
+
873
+ private matchesResponse(command: Command, response: HostResponse): boolean {
874
+ switch (command.type) {
875
+ case 'display_snapshot':
876
+ return response.type === 'display_snapshot' && response.window_id === command.window_id
877
+ case 'clipboard_read':
878
+ return response.type === 'clipboard_text' && response.window_id === command.window_id
879
+ case 'clipboard_write':
880
+ return response.type === 'clipboard_written' && response.window_id === command.window_id
881
+ case 'register_global_shortcut':
882
+ return (
883
+ response.type === 'global_shortcut_registered' &&
884
+ response.window_id === command.window_id &&
885
+ response.shortcut_id === command.shortcut_id
886
+ )
887
+ case 'unregister_global_shortcut':
888
+ return (
889
+ response.type === 'global_shortcut_unregistered' &&
890
+ response.window_id === command.window_id &&
891
+ response.shortcut_id === command.shortcut_id
892
+ )
893
+ case 'post_notification':
894
+ return (
895
+ response.type === 'notification_posted' &&
896
+ response.window_id === command.window_id &&
897
+ response.notification_id === command.notification_id
898
+ )
899
+ case 'withdraw_notification':
900
+ return (
901
+ response.type === 'notification_withdrawn' &&
902
+ response.window_id === command.window_id &&
903
+ response.notification_id === command.notification_id
904
+ )
905
+ case 'open_file_grant':
906
+ case 'close_file_grant':
907
+ return (
908
+ response.type ===
909
+ (command.type === 'open_file_grant' ? 'file_grant_opened' : 'file_grant_closed') &&
910
+ response.window_id === command.window_id &&
911
+ response.grant_id === command.grant_id
912
+ )
913
+ case 'file_request':
914
+ return (
915
+ response.type === 'file_result' &&
916
+ response.window_id === command.window_id &&
917
+ response.grant_id === command.grant_id &&
918
+ fileResultAnswers(command.operation, response.result)
919
+ )
920
+ case 'spawn_process':
921
+ case 'write_process':
922
+ case 'close_process_input':
923
+ case 'resize_process':
924
+ case 'signal_process':
925
+ case 'acknowledge_process_output':
926
+ case 'release_process':
927
+ return (
928
+ response.type ===
929
+ (command.type === 'spawn_process'
930
+ ? 'process_spawned'
931
+ : command.type === 'release_process'
932
+ ? 'process_released'
933
+ : 'process_updated') &&
934
+ response.window_id === command.window_id &&
935
+ response.process_id === command.process_id
936
+ )
937
+ case 'show_file_dialog':
938
+ return (
939
+ response.type === 'file_dialog_opened' &&
940
+ response.window_id === command.window_id &&
941
+ response.dialog_id === command.dialog_id
942
+ )
943
+ case 'close_file_dialog':
944
+ return (
945
+ response.type === 'file_dialog_closed' &&
946
+ response.window_id === command.window_id &&
947
+ response.dialog_id === command.dialog_id
948
+ )
949
+ case 'show_message_dialog':
950
+ return (
951
+ response.type === 'message_dialog_opened' &&
952
+ response.window_id === command.window_id &&
953
+ response.dialog_id === command.dialog_id
954
+ )
955
+ case 'close_message_dialog':
956
+ return (
957
+ response.type === 'message_dialog_closed' &&
958
+ response.window_id === command.window_id &&
959
+ response.dialog_id === command.dialog_id
960
+ )
961
+ case 'show_context_menu':
962
+ case 'close_context_menu':
963
+ return (
964
+ response.type ===
965
+ (command.type === 'show_context_menu'
966
+ ? 'context_menu_opened'
967
+ : 'context_menu_closed') &&
968
+ response.window_id === command.window_id &&
969
+ response.menu_id === command.menu_id
970
+ )
971
+ case 'register_drop_target':
972
+ case 'unregister_drop_target':
973
+ return (
974
+ response.type ===
975
+ (command.type === 'register_drop_target'
976
+ ? 'drop_target_registered'
977
+ : 'drop_target_unregistered') &&
978
+ response.window_id === command.window_id &&
979
+ response.target_id === command.target_id
980
+ )
981
+ case 'resolve_dropped_item':
982
+ return (
983
+ response.type === 'dropped_item_resolved' &&
984
+ response.window_id === command.window_id &&
985
+ response.target_id === command.target_id &&
986
+ response.drop_id === command.drop_id &&
987
+ response.item === command.item &&
988
+ response.root === command.root
989
+ )
990
+ case 'set_app_menu':
991
+ return response.type === 'app_menu_set' && response.menu_id === command.menu_id
992
+ case 'open':
993
+ return (
994
+ response.type === 'opened' &&
995
+ response.window_id === command.window_id &&
996
+ response.root_id === command.root_id &&
997
+ response.tree_revision === 0
998
+ )
999
+ case 'batch':
1000
+ return (
1001
+ response.type === 'applied' &&
1002
+ response.window_id === command.window_id &&
1003
+ response.tree_revision === command.base_revision + 1
1004
+ )
1005
+ case 'hello':
1006
+ case 'close':
1007
+ case 'shutdown':
1008
+ return response.type === 'ack'
1009
+ }
1010
+ }
1011
+
1012
+ private closedError(): Error {
1013
+ return this.failure ?? new Error('Native host is not running')
1014
+ }
1015
+
1016
+ private fail(cause: unknown): void {
1017
+ if (this.failure) return
1018
+ this.failure = cause instanceof Error ? cause : new Error(String(cause))
1019
+ this.startupTrace?.failure(this.failure, this.stderrTail, this.process.pid)
1020
+ this.phase = 'closed'
1021
+ for (const request of this.pending.values()) {
1022
+ clearTimeout(request.timer)
1023
+ request.reject(this.failure)
1024
+ }
1025
+ this.pending.clear()
1026
+ this.events.length = 0
1027
+ this.waiting?.reject(this.failure)
1028
+ this.waiting = undefined
1029
+ this.cancelOutput()
1030
+ this.process.kill('SIGKILL')
1031
+ }
1032
+ }