fix(tui): share runtime with external TSX plugins
This commit is contained in:
parent
85ea15e56d
commit
bd406b9619
5 changed files with 128 additions and 2 deletions
|
|
@ -77,6 +77,11 @@
|
||||||
"bun": "./src/plugin/runtime-plugin-support.bun.ts",
|
"bun": "./src/plugin/runtime-plugin-support.bun.ts",
|
||||||
"node": "./src/plugin/runtime-plugin-support.node.ts",
|
"node": "./src/plugin/runtime-plugin-support.node.ts",
|
||||||
"default": "./src/plugin/runtime-plugin-support.node.ts"
|
"default": "./src/plugin/runtime-plugin-support.node.ts"
|
||||||
|
},
|
||||||
|
"#plugin-loader": {
|
||||||
|
"bun": "./src/plugin/loader.bun.ts",
|
||||||
|
"node": "./src/plugin/loader.node.ts",
|
||||||
|
"default": "./src/plugin/loader.node.ts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import type { Page, Slot, SlotName } from "@opencode-ai/plugin/tui/context"
|
||||||
import { createStore, produce, reconcile as reconcileStore } from "solid-js/store"
|
import { createStore, produce, reconcile as reconcileStore } from "solid-js/store"
|
||||||
import { isDeepEqual } from "remeda"
|
import { isDeepEqual } from "remeda"
|
||||||
import "#runtime-plugin-support"
|
import "#runtime-plugin-support"
|
||||||
|
import { preparePlugin } from "#plugin-loader"
|
||||||
import { useConfig } from "../config"
|
import { useConfig } from "../config"
|
||||||
import { useTuiLifecycle } from "../context/runtime"
|
import { useTuiLifecycle } from "../context/runtime"
|
||||||
import { errorMessage } from "../util/error"
|
import { errorMessage } from "../util/error"
|
||||||
|
|
@ -215,7 +216,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||||
const memo = local ? undefined : npmFailures.get(target)
|
const memo = local ? undefined : npmFailures.get(target)
|
||||||
const resolved = memo
|
const resolved = memo
|
||||||
? { status: "failed" as const, error: memo }
|
? { status: "failed" as const, error: memo }
|
||||||
: await resolvePlugin(target, local, options, previous, props.packages).catch((error) => ({
|
: await resolvePlugin(target, local, options, previous, props.packages, host.paths.state).catch((error) => ({
|
||||||
status: "failed" as const,
|
status: "failed" as const,
|
||||||
error: errorMessage(error),
|
error: errorMessage(error),
|
||||||
}))
|
}))
|
||||||
|
|
@ -439,6 +440,7 @@ async function resolvePlugin(
|
||||||
options: Readonly<Record<string, any>> | undefined,
|
options: Readonly<Record<string, any>> | undefined,
|
||||||
previous: Registration | undefined,
|
previous: Registration | undefined,
|
||||||
packages: PackageResolver,
|
packages: PackageResolver,
|
||||||
|
state: string,
|
||||||
) {
|
) {
|
||||||
// Package entrypoints never change within a session, so a loaded previous
|
// Package entrypoints never change within a session, so a loaded previous
|
||||||
// version needs no re-resolution (which could otherwise hit npm).
|
// version needs no re-resolution (which could otherwise hit npm).
|
||||||
|
|
@ -451,7 +453,7 @@ async function resolvePlugin(
|
||||||
const version = local ? freshSpecifier(entrypoint, (await stat(new URL(entrypoint))).mtimeMs) : entrypoint
|
const version = local ? freshSpecifier(entrypoint, (await stat(new URL(entrypoint))).mtimeMs) : entrypoint
|
||||||
if (previous && previous.version === version && sameOptions(previous.options, options))
|
if (previous && previous.version === version && sameOptions(previous.options, options))
|
||||||
return { status: "unchanged" as const, plugin: previous.plugin, version }
|
return { status: "unchanged" as const, plugin: previous.plugin, version }
|
||||||
const mod: { readonly default?: unknown } = await import(version)
|
const mod: { readonly default?: unknown } = await import(await preparePlugin(entrypoint, version, state))
|
||||||
if (!isPlugin(mod.default)) throw new Error(`Invalid V2 TUI plugin module: ${spec}`)
|
if (!isPlugin(mod.default)) throw new Error(`Invalid V2 TUI plugin module: ${spec}`)
|
||||||
return { status: "loaded" as const, plugin: mod.default, version }
|
return { status: "loaded" as const, plugin: mod.default, version }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
44
packages/tui/src/plugin/loader.bun.ts
Normal file
44
packages/tui/src/plugin/loader.bun.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
|
||||||
|
import { isCoreRuntimeModuleSpecifier, runtimeModuleIdForSpecifier } from "@opentui/core/runtime-plugin"
|
||||||
|
import { mkdir } from "node:fs/promises"
|
||||||
|
import path from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
|
||||||
|
const runtime = new Set([
|
||||||
|
"@opentui/solid",
|
||||||
|
"@opentui/solid/components",
|
||||||
|
"@opentui/solid/jsx-runtime",
|
||||||
|
"@opentui/solid/jsx-dev-runtime",
|
||||||
|
"solid-js",
|
||||||
|
"solid-js/store",
|
||||||
|
])
|
||||||
|
|
||||||
|
export async function preparePlugin(entrypoint: string, version: string, state: string) {
|
||||||
|
const source = fileURLToPath(entrypoint)
|
||||||
|
if (!source.endsWith(".tsx") && !source.endsWith(".jsx")) return version
|
||||||
|
const result = await Bun.build({
|
||||||
|
entrypoints: [source],
|
||||||
|
target: "bun",
|
||||||
|
format: "esm",
|
||||||
|
sourcemap: "inline",
|
||||||
|
plugins: [
|
||||||
|
createSolidTransformPlugin({
|
||||||
|
moduleName: runtimeModuleIdForSpecifier("@opentui/solid"),
|
||||||
|
resolvePath(specifier) {
|
||||||
|
if (!runtime.has(specifier) && !isCoreRuntimeModuleSpecifier(specifier)) return null
|
||||||
|
return runtimeModuleIdForSpecifier(specifier)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
external: [...runtime, "@opentui/core", "@opentui/core/testing"].flatMap((specifier) => [
|
||||||
|
specifier,
|
||||||
|
runtimeModuleIdForSpecifier(specifier),
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
if (!result.success) throw new Error(result.logs.join("\n"))
|
||||||
|
const directory = path.join(state, "tui-plugin-cache")
|
||||||
|
const output = path.join(directory, `${Bun.hash(version).toString(16)}.mjs`)
|
||||||
|
await mkdir(directory, { recursive: true })
|
||||||
|
await Bun.write(output, result.outputs[0]!)
|
||||||
|
return output
|
||||||
|
}
|
||||||
3
packages/tui/src/plugin/loader.node.ts
Normal file
3
packages/tui/src/plugin/loader.node.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export async function preparePlugin(_entrypoint: string, version: string, _state: string) {
|
||||||
|
return version
|
||||||
|
}
|
||||||
72
packages/tui/test/plugin-reactivity.test.tsx
Normal file
72
packages/tui/test/plugin-reactivity.test.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import { createComponent, createSignal, type JSX } from "solid-js"
|
||||||
|
import { testRender } from "@opentui/solid"
|
||||||
|
import { mkdir, writeFile } from "node:fs/promises"
|
||||||
|
import path from "node:path"
|
||||||
|
import { preparePlugin } from "../src/plugin/loader.bun"
|
||||||
|
import "../src/plugin/runtime-plugin-support.bun"
|
||||||
|
import { tmpdir } from "./fixture/fixture"
|
||||||
|
|
||||||
|
test("an external TSX plugin uses the host Solid runtime", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const root = path.join(tmp.path, ".opencode")
|
||||||
|
const source = path.join(root, "plugins", "tui", "reactive.tsx")
|
||||||
|
const helper = path.join(root, "plugins", "tui", "signal.ts")
|
||||||
|
const localSolid = path.join(root, "node_modules", "solid-js")
|
||||||
|
const localOpenTui = path.join(root, "node_modules", "@opentui", "solid")
|
||||||
|
await Promise.all([
|
||||||
|
mkdir(path.dirname(source), { recursive: true }),
|
||||||
|
mkdir(localSolid, { recursive: true }),
|
||||||
|
mkdir(localOpenTui, { recursive: true }),
|
||||||
|
])
|
||||||
|
await Promise.all([
|
||||||
|
writeFile(
|
||||||
|
path.join(localSolid, "package.json"),
|
||||||
|
JSON.stringify({ name: "solid-js", type: "module", main: "index.js" }),
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(localSolid, "index.js"),
|
||||||
|
"export const createSignal = () => { throw new Error('local Solid used') }\n",
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(localOpenTui, "package.json"),
|
||||||
|
JSON.stringify({ name: "@opentui/solid", type: "module", main: "index.js" }),
|
||||||
|
),
|
||||||
|
writeFile(path.join(localOpenTui, "index.js"), "throw new Error('local OpenTUI used')\n"),
|
||||||
|
writeFile(helper, 'export { createSignal, onCleanup } from "solid-js"\n'),
|
||||||
|
writeFile(
|
||||||
|
source,
|
||||||
|
`
|
||||||
|
import { createSignal, onCleanup } from "./signal"
|
||||||
|
|
||||||
|
export const signal = createSignal
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: "test.reactive",
|
||||||
|
setup(context: any) {
|
||||||
|
context.ui.slot("home.footer", () => {
|
||||||
|
const [count, setCount] = createSignal(0)
|
||||||
|
const timer = setTimeout(() => setCount(1), 10)
|
||||||
|
onCleanup(() => clearTimeout(timer))
|
||||||
|
return <box><text>count:{count()}</text></box>
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
const plugin = await import(await preparePlugin(new URL(`file://${source}`).href, `${source}?mtime=1`, tmp.path))
|
||||||
|
expect(plugin.signal).toBe(createSignal)
|
||||||
|
let slot: ((input: object) => JSX.Element) | undefined
|
||||||
|
await plugin.default.setup({ ui: { slot: (_name: string, render: typeof slot) => (slot = render) } })
|
||||||
|
if (!slot) throw new Error("Plugin did not register its slot")
|
||||||
|
|
||||||
|
const setup = await testRender(() => createComponent(slot!, {}), { width: 20, height: 2 })
|
||||||
|
try {
|
||||||
|
expect(await setup.waitForFrame((frame) => frame.includes("count:0"))).toContain("count:0")
|
||||||
|
expect(await setup.waitForFrame((frame) => frame.includes("count:1"))).toContain("count:1")
|
||||||
|
} finally {
|
||||||
|
setup.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue