@soapbox.pub/nostr-canvas
v0.10.1
Published
Programmable Nostr tiles — runtime, Lua sandbox, and React bindings for the nostr-canvas NIP.
Readme
nostr-canvas
A Nostr client framework where UI is defined by tiles — mini apps written in Lua and published as kind-30207 Nostr events. Any compatible client can fetch and run tiles without modification.
See the demo for a running example.
- Tile scripting API and kind-30207 event format: NIP.md
- Host integration guide and philosophy: PHILOSOPHY.md
- Full example host app:
app/ - Sample tiles:
tiles/
Installation
pnpm add @soapbox.pub/nostr-canvasEntry points
| Import path | Contents |
| ---------------------------------------- | ---------------------------------------------------------- |
| @soapbox.pub/nostr-canvas | Core runtime, types, adapter interface |
| @soapbox.pub/nostr-canvas/react | React provider, hooks, and TileView component |
| @soapbox.pub/nostr-canvas/components | Optional default implementations of Feed, Comments, Markdown |
Quick start (React)
1. Implement the adapter
The adapter bridges the runtime to your app's existing relay pool, signer, and
profile cache. Only subscribe is required; all other methods are optional.
import type { NostrAdapter } from "@soapbox.pub/nostr-canvas";
const adapter: NostrAdapter = {
// Required: wire relay subscriptions
subscribe(filter, onEvent) {
const sub = pool.subscribe(relays, filter, { onevent: onEvent });
return () => sub.close();
},
// Optional: expose capabilities as your app supports them
getPublicKey: () => window.nostr.getPublicKey(),
// publishEvent signs AND publishes in one step, returning the signed event
publishEvent: async (event) => {
const signed = await window.nostr.signEvent(event);
await pool.publish(relays, signed);
return signed;
},
getProfile: (pubkey, cb) => profileCache.subscribe(pubkey, cb),
navigate: (target) => router.push(target),
};2. Wrap your app in the provider
import { NostrCanvasProvider } from "@soapbox.pub/nostr-canvas/react";
function App() {
return (
<NostrCanvasProvider adapter={adapter} options={{ onGrantDecision }}>
{/* your app */}
</NostrCanvasProvider>
);
}3. Install a tile and render it
import { useNostrCanvas, TileView } from "@soapbox.pub/nostr-canvas/react";
import { Feed, Comments, Markdown } from "@soapbox.pub/nostr-canvas/components";
import { NEvent } from "./your-nevent-component";
function MyView() {
const { runtime } = useNostrCanvas();
useEffect(() => {
runtime.registerFromEvent(kind30207Event);
}, []);
return (
<TileView
identifier="[email protected]:longform"
placement="main"
components={{ Feed, Comments, Markdown, NEvent }}
/>
);
}TileView mounts the tile, subscribes to its output, and renders the resulting
TileOutput tree as unstyled semantic HTML with nc-tile-node-* CSS classes.
Apply your own styles on top.
Component slots
Complex node types (feed, comments, markdown, nevent) are delegated to
host-supplied components via the components prop. The /components subpath
ships ready-to-use defaults for the first three:
import { Feed, Comments, Markdown } from "@soapbox.pub/nostr-canvas/components";These are optional and tree-shaken when not imported. NEvent is always
host-supplied — you know best how to render a Nostr note card in your app.
Listening to runtime events
const unsub = runtime.on("notify", ({ message, variant }) => {
showToast(message, variant);
});
// cleanup
unsub();Available event types: tile-output, tile-removed, nav-changed,
registrations-changed, settings-fields-changed, notify. See
RuntimeEventMap for full type signatures.
Core API
const runtime = new TileRuntime(adapter, {
// Choose which of a tile's declared capabilities to grant at install time
onGrantDecision: (identifier, declared) => declared,
});
// Install a tile from a kind-30207 event
runtime.registerFromEvent(event);
// Create an instance
const tileId = runtime.createTile("[email protected]:weather", {
placement: "widget",
props: { location: "London" },
});
// Subscribe to its output
const unsub = runtime.onTileOutput(tileId, (output) => render(output));
// Deliver a user interaction (handlerName is the global Lua function name)
runtime.deliverInputEvent(tileId, "on_my_button", { value: 42 });
// Tear down
runtime.removeTile(tileId);
runtime.destroy();Worker execution
Tile Lua engines always run off the main thread, in a shared pool of Worker
threads, keeping the main thread free for UI rendering. The pool size is
Math.min(navigator.hardwareConcurrency, 8); each worker hosts multiple engines
simultaneously.
By default the runtime creates its own pool. To share one pool across provider remounts (recommended), create it once and pass it in:
import { WorkerExecutor } from "@soapbox.pub/nostr-canvas";
import { NostrCanvasProvider } from "@soapbox.pub/nostr-canvas/react";
const pool = useMemo(() => WorkerExecutor.createPool(), []);
<NostrCanvasProvider
adapter={adapter}
options={{ onGrantDecision }}
workerPool={pool}
/>WorkerExecutor.createPool() uses the standard
new Worker(new URL(...), { type: 'module' }) pattern that Vite, Rollup,
webpack, and other ESM-aware bundlers handle automatically — the worker entry is
bundled for you, with no plugin or manual step required.
Outside the React bindings, pass the pool straight to the runtime:
const pool = WorkerExecutor.createPool();
const runtime = new TileRuntime(adapter, { workerPool: pool });Development
pnpm i && pnpm run dev # start the example app dev server
pnpm build # build the library