Kunkun
Building Extensions

Worker Extension

Raycast-style React extensions rendered by the host, in a Web Worker or Node.js process.

Worker extensions run React components in a sandbox (Web Worker or Node.js/Deno process). You write React with primitives from @kunkunsh/sdk/ui; the host renders them as native components — a Raycast-like experience with consistent styling. The mechanics are in How Extensions Run.

Modes

ModeRuntimeUINode.js
worker-viewWeb Workerhost rendersNo
node-viewNode.js / Deno processhost rendersYes
worker-headlessWeb WorkernoneNo
node-headlessNode.js / Deno processnoneYes

Project structure

package.json
build.ts

Setup

mkdir my-worker-ext && cd my-worker-ext
pnpm init && pnpm add @kunkunsh/sdk react valibot
pnpm add -D @types/react @types/bun

Build script

// build.ts
import { dedupeReact, kunkunCommandPlugin } from "@kunkunsh/sdk/build";
import type { BunPlugin } from "bun";

// Browser target → worker-view
await Bun.build({
  entrypoints: ["./src/App.tsx"],
  outdir: "./dist",
  target: "browser", format: "esm", minify: true,
  plugins: [kunkunCommandPlugin({ mode: "view" }) as BunPlugin, dedupeReact(import.meta.dir)],
});

// Node target → node-view
await Bun.build({
  entrypoints: ["./src/App.tsx"],
  outdir: "./dist/node",
  target: "node", format: "esm", minify: true,
  plugins: [kunkunCommandPlugin({ mode: "view" }) as BunPlugin, dedupeReact(import.meta.dir)],
});

kunkunCommandPlugin injects the bootstrap and RPC wiring. dedupeReact is required — two React copies would break the reconciler.

dist must be self-contained: Bun bundles JS deps by default, so avoid external entries unless the host provides the dependency. Native .node addons can't be inlined — package their loader + platform files, or lazy-load them.

The component

import { useState } from "react";
import { Button, Div, H2, H3, P, Hr } from "@kunkunsh/sdk/ui";
import { popToRoot, showToast, Toast, Clipboard, LocalStorage } from "@kunkunsh/sdk";

export default function App() {
  const [text, setText] = useState("");
  return (
    <Div className="p-6" style={{ display: "flex", flexDirection: "column", gap: 16 }}>
      <H2>My Worker Extension</H2>
      <Hr />
      <Button title="Read Clipboard" variant="outline" onClick={async () => setText(await Clipboard.readText())} />
      <Button title="Toast" variant="primary" onClick={() => showToast({ title: "Hi", style: Toast.Style.Success })} />
      <Button title="Back" variant="outline" onClick={() => popToRoot()} />
      {text && <P>{text}</P>}
    </Div>
  );
}

Import UI primitives from @kunkunsh/sdk/ui:

import { Button, Div, H2, H3, P, Code, Pre, Hr, Image, List, Grid, Form, TextField, TextArea, Select, Checkbox } from "@kunkunsh/sdk/ui";
<List>
  <List.Item title="Item" subtitle="Subtitle" icon="mdi:file" onClick={() => {}} />
</List>

<Form>
  <TextField title="Name" value={name} onChange={setName} />
  <Select title="Choice" value={choice} onChange={setChoice}
    options={[{ label: "A", value: "a" }, { label: "B", value: "b" }]} />
</Form>

Manifest

{
  "kunkun": {
    "identifier": "com.you.my-worker-ext",
    "name": "My Worker Extension",
    "permissions": ["clipboard-read", "clipboard-write", "storage"],
    "commands": [
      { "name": "main", "title": "My Command", "mode": "worker-view", "main": "dist/App.js" },
      { "name": "with-fs", "title": "Node Version", "mode": "node-view", "main": "dist/node/App.js" }
    ]
  }
}

Available APIs

Both worker-view and node-view get the full host API surface over RPC — Clipboard, LocalStorage, db, showToast, showHUD, popToRoot, getEnvironment, network.fetch, path, and also fs, shell, dialog, permissions, and spawnBackend. Every call is permission-gated: declare what you use in the manifest (e.g. a scoped shell permission listing the programs you run).

What node-view additionally offers is the runtime itself: your bundle runs in a Node.js/Deno process, so you can use Node built-ins (child_process, fs, native .node addons) directly instead of going through the host API. Prefer worker-view unless you need that.

See the full surface in the SDK Reference.

Live-updating lists

Worker and node views can bind directly to record storage with useQuery, so the list re-renders whenever the data changes — no manual refetch:

import { useQuery } from "@kunkunsh/sdk/utils";
import { List } from "@kunkunsh/sdk/ui";

function Bookmarks() {
  const { data } = useQuery<{ title: string; url: string }>("bookmarks", { orderBy: [["createdAt", "desc"]] });
  return <List>{data?.map((r) => <List.Item key={r.id} title={r.data.title} subtitle={r.data.url} />)}</List>;
}

Real examples

  • youtube-tools (service + worker) and youtube-podcast-generator (worker-view consumer)
  • port-manager — worker view, no-view command, and a menu-bar command in one extension
  • git-repos — a React view scanning local repos

On this page