Files
orca/src/main/plugins/plugin-host-runtime.ts
T
Neil 991a3fe963 chore(lint): update oxlint to 1.77 and enable no-op cleanup rules (#13901)
Enable eleven oxlint rules that simplify code without changing behavior, and fix
every existing violation. Each candidate was gated on measured cost rather than
assumption, so rules that regressed runtime performance or type checking were
dropped instead of suppressed.

typescript/no-redundant-type-constituents is the largest addition: 113 sites, no
autofix. Dead constituents are deleted. Where the redundant literal existed to
document intent (`string | 'all'`), it is preserved as `(string & {})`, which
keeps the autocomplete hint the original code was reaching for instead of
flattening it away. The rule also caught a broken import —
remote-shared-control-retirement-probe.ts pulled RuntimeStatus from
src/shared/types, which does not export it, so the type silently degraded to
`any`; no tsconfig covers that file, so tsc never saw it.

oxlint stays at 1.77.0 rather than 1.78.0 because .npmrc sets
minimum-release-age=4320 and 1.78.0 is younger than that window.

Rules evaluated and rejected, with what disqualified each:
- prefer-string-raw: String.raw is a runtime call, not a literal (184x slower)
- prefer-string-replace-all: 26% slower
- text-encoding-identifier-case: ~5% slower, reproducible
- prefer-spread: [...str] is 110% slower than split('') and differs on surrogates
- no-implicit-coercion: `!!x` narrows types and `Boolean(x)` does not (22 tsc errors)
- prefer-arrow-callback: arrows are not constructible, breaking `new` on mocks
- object-shorthand: rewrites source text asserted by a tracked reliability gate
- switch-case-braces: pushes ten files past max-lines, which cannot be suppressed
- no-useless-switch-case: drops `case undefined:` that switch-exhaustiveness-check needs
- arrow-body-style: 115 violations have no fix, and it breaks max-lines
- newline-after-import: false-positives on the leading-semicolon ASI idiom

electron-vite-output-contract asserted on the literal
Object.prototype.hasOwnProperty.call text; retarget it to Object.hasOwn, which
rejects inherited keys identically.
2026-08-11 18:19:43 -07:00

211 lines
7.3 KiB
TypeScript

import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import {
pluginWorkerParentMessageSchema,
type PluginWorkerChildMessage
} from '../../shared/plugins/plugin-host-protocol'
import type { PluginEventName } from '../../shared/plugins/plugin-manifest'
/**
* Message-loop core of the out-of-process plugin worker. Electron-free and
* side-effect-free (send/import/exit are injected) so it unit-tests without
* forking a real child process; `plugin-host-entry.ts` wires it to the fork
* IPC channel.
*/
export type PluginHostCallError = Error & { code?: string }
/** API surface handed to a plugin's `activate(orca)` export. Everything is
* EXPERIMENTAL until pluginApi v1 freezes. */
export type PluginWorkerOrcaApi = {
/** Register the handler for a command declared in the manifest. */
commands: {
register(commandId: string, handler: (args: unknown) => unknown): void
}
/** Handle an event the manifest subscribed to (`contributes.events`). */
events: {
on(event: PluginEventName, handler: (payload: unknown) => void | Promise<void>): void
}
/** Call a host API method (capability-gated host-side). */
host: {
call(method: string, params?: unknown): Promise<unknown>
}
/** Consented capability kinds (informational — the host re-gates). */
grantedCapabilities: readonly string[]
log(message: string): void
}
export type PluginWorkerRuntimeOptions = {
send: (message: PluginWorkerChildMessage) => void
importModule?: (specifier: string) => Promise<unknown>
exit?: (code: number) => void
}
export type PluginWorkerRuntime = {
handleMessage(raw: unknown): Promise<void>
}
function toErrorMessage(error: unknown): string {
return error instanceof Error ? (error.stack ?? error.message) : String(error)
}
export function createPluginWorkerRuntime(
options: PluginWorkerRuntimeOptions
): PluginWorkerRuntime {
const send = options.send
const importModule = options.importModule ?? ((specifier: string) => import(specifier))
const exit = options.exit ?? ((code: number) => process.exit(code))
const commandHandlers = new Map<string, (args: unknown) => unknown>()
const eventHandlers = new Map<string, ((payload: unknown) => void | Promise<void>)[]>()
const pendingHostCalls = new Map<
number,
{ resolve: (value: unknown) => void; reject: (error: PluginHostCallError) => void }
>()
let nextHostCallId = 0
let initialized = false
let shuttingDown = false
let deactivate: (() => unknown) | null = null
async function handleInit(input: {
pluginRoot: string
mainEntry: string
grantedCapabilities: string[]
}): Promise<void> {
if (initialized) {
send({ type: 'log', level: 'warn', message: 'ignoring duplicate init message' })
return
}
initialized = true
// Why: file URL import keeps ESM plugin entries working on Windows paths.
// Why: manifest paths accept either portable separator; split explicitly
// so a Windows-authored plugin also imports on macOS/Linux and vice versa.
const entryUrl = pathToFileURL(join(input.pluginRoot, ...input.mainEntry.split(/[\\/]/))).href
const module = (await importModule(entryUrl)) as { default?: unknown; deactivate?: unknown }
const activate = module?.default
if (typeof activate !== 'function') {
throw new Error(`plugin entry ${input.mainEntry} has no default-exported activate function`)
}
if (module.deactivate !== undefined && typeof module.deactivate !== 'function') {
throw new Error(`plugin entry ${input.mainEntry} has a non-function deactivate export`)
}
deactivate = (module.deactivate as (() => unknown) | undefined) ?? null
const orca: PluginWorkerOrcaApi = {
commands: {
register(commandId, handler) {
commandHandlers.set(commandId, handler)
}
},
events: {
on(event, handler) {
const handlers = eventHandlers.get(event) ?? []
handlers.push(handler)
eventHandlers.set(event, handlers)
}
},
host: {
call(method, params) {
const callId = nextHostCallId++
return new Promise<unknown>((resolve, reject) => {
pendingHostCalls.set(callId, { resolve, reject })
send({ type: 'hostCall', callId, method, params })
})
}
},
grantedCapabilities: input.grantedCapabilities,
log(message) {
send({ type: 'log', level: 'info', message: String(message).slice(0, 8192) })
}
}
await activate(orca)
send({ type: 'ready', commands: [...commandHandlers.keys()] })
}
return {
async handleMessage(raw) {
const parsed = pluginWorkerParentMessageSchema.safeParse(raw)
if (!parsed.success) {
send({ type: 'log', level: 'warn', message: 'ignoring malformed parent message' })
return
}
const message = parsed.data
try {
switch (message.type) {
case 'init': {
await handleInit(message)
return
}
case 'invokeCommand': {
const handler = commandHandlers.get(message.commandId)
if (!handler) {
send({
type: 'commandResult',
callId: message.callId,
ok: false,
error: `no handler registered for command ${message.commandId}`
})
return
}
try {
const value = await handler(message.args)
send({ type: 'commandResult', callId: message.callId, ok: true, value })
} catch (error) {
send({
type: 'commandResult',
callId: message.callId,
ok: false,
error: toErrorMessage(error)
})
}
return
}
case 'deliverEvent': {
const handlers = eventHandlers.get(message.event) ?? []
for (const handler of handlers) {
try {
await handler(message.payload)
} catch (error) {
send({ type: 'log', level: 'error', message: toErrorMessage(error) })
}
}
send({ type: 'eventAck', eventId: message.eventId })
return
}
case 'hostResult': {
const pending = pendingHostCalls.get(message.callId)
if (!pending) {
return
}
pendingHostCalls.delete(message.callId)
if (message.ok) {
pending.resolve(message.value)
} else {
const error: PluginHostCallError = new Error(message.error ?? 'host call failed')
error.code = message.errorCode
pending.reject(error)
}
return
}
case 'shutdown': {
if (shuttingDown) {
return
}
shuttingDown = true
try {
await deactivate?.()
} catch (error) {
send({ type: 'log', level: 'error', message: toErrorMessage(error).slice(0, 8192) })
}
exit(0)
}
}
} catch (error) {
// Why: an init/activation failure leaves the worker useless; report
// and die so the parent surfaces the error instead of hanging on
// the ready timeout.
send({ type: 'fatal', error: toErrorMessage(error) })
exit(1)
}
}
}
}