npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@rise-x/apps-sdk

v0.11.0

Published

SDK for building federated apps on top of the Rise-X Diana shell. Includes a CLI for scaffolding new apps.

Readme

@rise-x/apps-sdk

SDK for building federated apps on top of the Rise-X Diana shell.

  • Runtime hooks/accessorsuseShellUser, useShellEnvironment, useShellNavigate, getShellApi, getShellApiV4, getShellAi.
  • Connectors@rise-x/apps-sdk/connectors exposes typed, higher-level API wrappers (flows, work, assets, agents) so apps don't hand-roll multi-step API calls.
  • Lifecycle hooksonInstall, onUpdate, onUninstall that apps export from a federated ./lifecycle module; the shell invokes them best-effort.
  • Standalone-dev mockcreateMockShell for running an app outside the shell.
  • Shell-side installer@rise-x/apps-sdk/shell exposes installShellBridge for the host to wire the bridge.
  • Scaffolder CLInpx @rise-x/apps-sdk init <name> to create a new app in the current directory.

Quick start (scaffold)

npx @rise-x/apps-sdk init my-thing
cd my-thing
pnpm start

CLI flags

| Flag | Default | Meaning | | --- | --- | --- | | --pm=<npm\|yarn\|pnpm> | auto-detected | package manager used for install | | --port=<n> | 5101 | dev-server port in the generated app | | --skip-install | off | skip <pm> install after scaffolding | | --json | off | emit { path, slug, scope, pkgName, port, pm } on stdout |

Design assets (static HTML mocks)

The package ships what an agent needs to build static single-file HTML design mocks without repo access:

  • build/ui/styles.css — the compiled design-system stylesheet, Inter font embedded, Tailwind preflight included — inline it into the mock. The preflight matters: a standalone HTML file has no host to establish box-sizing: border-box, and without it every w-full control with horizontal padding renders wider than its container.
  • build/ui/demo.html — the Shell design-system demo page, pre-rendered to static HTML: ground-truth markup for every component, generated at build time from Shell/src/components/__demo__. Fully self-contained — it inlines styles.css above, the same file you inline into your mock, so markup copied out of the demo renders in your mock exactly as it renders there. Utilities you invent for the mock's own scaffolding are a different matter: only classes used by the design system or by the demo page have rules, so write mock layout as plain CSS. Specimens styled by CSS outside the design system (the map's leaflet sheet, the Ask Diana animations) render unstyled in the demo and in mocks.
  • build/ui/classes.json — the exact class string for every variant of every variant-bearing component, generated from the components' own cva functions and merged the way the component merges them. Use it for a combination the demo doesn't happen to render (a default button at size sm) instead of splicing fragments from two specimens. Each entry names its dimensions, and keys join their values in that order: button.classes["default|sm"]. Entries are named after the cva function, not the file it lives in — trend and confidence, not statistic and ai.
  • template/src/App.tsx — the canonical app layout (left-rail chrome + screens); scaffolded apps start from it, and design mocks follow the same composition.

Navigating demo.html (for agents)

The file is pretty-printed and starts with a navigation guide plus a JSON table of contents (<script type="application/json" id="demo-toc">) listing every section, screen, and the data-slot components each section demonstrates. Read the first ~80 lines for the TOC, then grep:

| To find | Grep for | | --- | --- | | a section (component family / pattern) | data-section="<id>" | | a full screen (app layout, AI chat, dashboard, ...) | data-screen-panel="<id>" | | one component's real markup | data-slot="<name>" |

Component props and variants live in the shipped types — build/ui/components/<name>.d.ts, named to match the data-slot prefixes (in the rise-x-app monorepo, the sources are packages/ui/src/components/).

Use in app code

import {
  useShellUser,
  useShellEnvironment,
  useShellNavigate,
  getShellApiV4,
} from '@rise-x/apps-sdk';

const user = useShellUser();
const env = useShellEnvironment();
const navigate = useShellNavigate();
const apps = getShellApiV4('apps');
const work = getShellApiV4('work');

UI components (@rise-x/apps-sdk/ui)

The Rise-X design system (shadcn components on the shell's Tailwind theme) is re-exported for apps:

import { Button, Dialog, DialogContent, Input, cn } from '@rise-x/apps-sdk/ui';

App layout (AppFrame / AppRail / AppContent)

The root layout contract, scaffolded into every new app:

<AppFrame mobileNav="tabs">
  <AppRail>{/* Nav / NavItem rail */}</AppRail>
  <AppContent>{/* screens; the app's ONE scroll container */}</AppContent>
</AppFrame>

The next-gen shell hands the app a full-height region that extends under its floating top bar and publishes the bar's height as --topbar-h on the document. AppRail stays fixed on the left and starts below the bar; AppContent owns the scroll and slides content behind the bar's blur. Both pad with var(--topbar-h, 0px), so in hosts without a floating bar (the classic shell, standalone dev) the frame degrades to a plain rail-plus-content layout. Never hardcode a bar height and never add a second page-level scroller.

The frame is responsive by default, and it measures itself, not the window: AppFrame is a CSS container, so the layout answers to the region the app was given (the shell's chrome takes space the app never gets). Below the frame's 2xl container width (672px) it stacks and NavSection headers hide.

Being a container also makes the frame the containing block for position: fixed descendants, in every mode: a fixed action bar or FAB you render inside AppFrame pins to the app's region, not to the browser window. That is usually what you want. For something that genuinely belongs to the window, portal it to document.body the way the kit's Dialog, Sheet and Popover already do.

mobileNav picks what the rail becomes at that width:

| Value | Narrow rail | | --- | --- | | "strip" (default) | A horizontal, scrollable nav strip under the top bar. | | "tabs" | A bottom tab bar, the native phone pattern — icon over label, one equal share each, safe-area padding. The scaffold sets this. |

In tabs mode an app with more than five nav items shows the first four and a More tab that opens the rest in a bottom sheet; the wide rail still lists everything. Pass moreLabel on AppRail to localise the label. Don't hand-roll a tab bar, and don't write your own breakpoints around the frame; compose inside it.

Rendering an AppFrame anywhere in the app is the signal that the app owns its layout: the next-gen shell detects it and hands over the full-bleed region. Apps without one are compensated — the host pads and scrolls the region itself — so pre-frame bundles keep working, minus the fixed rail.

How it works: the SDK ships the typings; the runtime is the shell's own copy of @rise-x/ui, delivered through the Module Federation share scope — exactly how apps already consume React. That is deliberate: the components are styled by the shell's compiled Tailwind CSS, so only the shell's copy renders correctly, and there is never a second React/component instance.

Requirements in the app:

  1. It must be a Module Federation share. The preset does this for you:

    '@rise-x/ui': { singleton: true, requiredVersion: false, import: false },
  2. Federated, components render from the shell's copy. The preset's standalone branch aliases @rise-x/ui to the SDK's own compiled bundle (./ui/standalone) instead — a self-contained snapshot with its CSS self-injected, so components render for real with no shell. It's a snapshot, not the source of truth: verify in a host (or deployed) before release.

MapView

The one component with a real library behind it. It renders a Leaflet basemap and nothing else — markers, routes and heat layers are yours to add. Leaflet is fetched in its own chunk on first mount, so apps that never show a map pay nothing for it.

onReady hands back the map and the Leaflet module it was built from. Build layers off that L: the app then needs no leaflet dependency of its own, and a second copy of the library never meets the first one at map.addLayer.

Leaflet's own chrome is restyled to the kit — the zoom cluster is a grouped ghost control, tooltips match TooltipContent, popups match PopoverContent, and markers are the design system's ringed pin rather than Leaflet's blue PNG. All of it reads from the theme tokens, so it follows dark mode and an ecosystem rebrand without any work in the app. Tiles default to tiles="auto", which switches between the light and dark basemap live with the shell's theme.

import { MapView, mapPin } from '@rise-x/apps-sdk/ui';

<MapView
  center={[51.92, 4.48]}
  zoom={9}
  className="h-[320px] rounded-lg border border-border"
  onReady={(map, L) => {
    // Already the branded pin — no icon needed.
    L.marker([51.92, 4.48]).addTo(map).bindTooltip('Rotterdam');
    // tone: 'brand' | 'success' | 'warning' | 'error' | 'info'
    L.marker([52.01, 4.36], { icon: L.divIcon(mapPin({ tone: 'error' })) }).addTo(map);
  }}
/>;

tiles also takes a preset ('light', 'dark', 'streets', 'satellite'), a custom tile layer, or null for no basemap at all.

center/zoom/bounds set the initial view only — pan and zoom afterwards through the instance (map.flyTo(...)), so a re-render never yanks the map away from someone who is using it. The map fills its parent and re-fits itself whenever the box changes size, so it can sit in a panel, a split or a tab without the usual grey gutters. interactive={false} gives a static preview for cards and list rows, and children render over the map as overlays.

Lifecycle hooks

Expose a ./lifecycle module — the preset exposes it by default — and export any subset of the three hooks:

// src/lifecycle.ts
import type { InstallHook, UninstallHook, UpdateHook } from '@rise-x/apps-sdk';
import localforage from 'localforage';

export const onInstall: InstallHook = async ({ manifest, user, environment }) => {
  // first time this device sees the app — pre-warm caches, seed defaults, etc.
};

export const onUpdate: UpdateHook = async (ctx, { from, to }) => {
  // version bumped in the registry — run migrations if needed.
};

export const onUninstall: UninstallHook = async ({ manifest }) => {
  // drop whatever the app persisted
  await localforage.dropInstance({ name: `diana-app-${manifest.id}` });
};

./App and ./lifecycle are exposed by defineAppConfig() already; pass exposes only to add more:

// rsbuild.config.mts
defineAppConfig({ pkg, port: 5101, exposes: { './Widget': './src/Widget' } });

The shell invokes hooks best-effort: errors are logged, 10s timeout per hook, missing hooks skip silently. Hooks run inside the shell's page — getShellApi() / getShellApiV4() work from inside them.

Storage

Bring your own — install localforage (or IndexedDB, Cache API, etc.) as a direct dep. Put cleanup in onUninstall.

Connectors

@rise-x/apps-sdk/connectors wraps the raw clients with typed, framework-agnostic helpers. Prefer them over hand-rolled getShellApi* calls; fall back to the raw clients only for endpoints the connectors don't cover.

import { flows, work, assets, agents, ConnectorError } from '@rise-x/apps-sdk/connectors';

Flows — discovery

// Prefer ids over names: displayName is user-editable and name changes when a
// flow is rebuilt, so hardcoded name strings silently break later. Bake the
// flowOriginId into app config instead:
const FLOW_ORIGIN_ID = '7732039e-6c5e-4bca-9a23-3bb67b1a1b22';
const flow = await flows.get(FLOW_ORIGIN_ID); // flow + full step tree (merged)
// get/getConfig accept a concrete flow id or a flowOriginId — either
// resolves to the latest published version (steps and config included).
const config = await flows.getConfig(flow.id); // v4 step/task/layout grouping + nameMap
const layout = await flows.getLayout(config.steps[0].tasks[0].layoutId!);
const fields = flows.flattenLayoutFields(layout); // leaf form fields with data paths

// findTask/list's name-matching is for dev-time exploration or a user-facing
// search box, not for a shipped id — displayName isn't a stable reference:
const summaries = await flows.list({ search: 'risk' }); // statuses default ['Active']
const hit = await flows.findTask({ flow: 'Risk Management', task: 'Submit Risk' });
if (hit) console.log(hit.task.id, hit.step.displayName);

flows.list() lists WORK flows only. Asset-type (Entity) flows are not in it; those come from assets.types(), which also carries the entityType, the item count and the image the asset APIs need. The two listings are disjoint, so neither is a superset of the other and neither enumerates "all flows". Only flows.get() / findTask() resolve a flow of either kind by id.

This matters when you need a flowOriginId to pin a search: take a work origin id from flows.list(), and an asset origin id from assets.types(). Crossing them gives a valid guid of the wrong flow family, which matches nothing and returns an empty page with no error.

Work — items (read + write)

// Create a work item by starting a flow (flow.id or a bare flowOriginId —
// the platform resolves either to the latest published version).
const created = await work.start({ flowId: flow.id, data: { title: 'New risk' } });

// Connect to it.
const detail = await work.get(created.id);
const data = await work.getData(created.id, { path: '$.identifyPotentialRisk' });

// Update work data (operations: Set | Push | Pull | Unset | AddToSet | Merge).
await work.patchData(created.id, {
  originId: detail.flowOriginId!,
  path: '$.identifyPotentialRisk.riskDescription',
  value: 'Updated from an app',
});

// Complete a task action. Patch data first — submit does not merge data.
const action = detail.actions[0];
const actionName = action?.eventName ?? action?.name;
if (!actionName) throw new Error('work item has no available action');
await work.submit({ workId: created.id, actionName });

// Remove a work item (no server-side undo).
await work.delete(created.id);

// Paginated listing — `properties` shapes each row's `data` subtree.
// Unlike start()/get()/getConfig(), listing filters STRICTLY by flowOriginId —
// a concrete flow id here silently returns an empty page, not an error.
for await (const row of work.iterate({
  flow, // object from flows.list()/get(), or a bare flowOriginId string
  properties: { riskLevel: '$.assessRiskByRiskCustodian.governingRiskLevel' },
  maxItems: 200,
})) {
  console.log(row.displayName, row.data?.riskLevel);
}

const audit = await work.getAudit(created.id);

work.search() is the v4 index behind the same domain. Prefer it over work.list()/iterate() for listings that need the platform's own status, state, timestamps or assignees: those arrive on the row, so no per-item fetch is needed, and a filter tree replaces client-side narrowing.

Every search must pin flowOriginId with an equals or in condition, at the top level or inside and groups — a pin inside an or branch doesn't narrow the search and is rejected the same way. Environment-wide searches are what made the slow query paths possible, so the server now rejects an unpinned search with a 400 whatever else the filter says. Both work.search() and assets.search() therefore require filter in their types.

const page = await work.search({
  filter: {
    and: [
      // Required, always. `in` with several origin ids works too.
      { field: 'flowOriginId', operator: 'equals', values: [flowOriginId] },
      // `status` values are Open/Closed/Completed/Deleted/Ok. Step-level values
      // like InProgress belong to `flowState` — a different field.
      { field: 'status', operator: 'in', values: ['Open'] },
    ],
  },
  // Projection: `data.*` paths resolve against the pinned flow's schema and
  // arrive under `row.data` with the `data.` prefix stripped. `statusDisplay`
  // carries the label and roleName.
  fields: ['status', 'assignedUsers', 'statusDisplay', 'data.pricing.rate'],
  // Sorting and the createdBy/lastModifiedBy filters need a pinned search; on
  // an older API build they may fail, so pin first before assuming they're
  // broken.
  sort: [{ field: 'created', direction: 'desc' }],
  pageSize: 50,
});

for (const row of page.items) {
  console.log(row.workCode, row.status, row.created, row.assignedUsers, row.data?.pricing);
}
// page.hasMore drives the next page; pass includeTotalCount for an exact count.
// Caveat: `row.created`/`row.lastModified` are ISO strings, but date values
// inside `row.data` pass through as `{date, ticks, offset}` objects — don't
// feed them to `new Date()` directly; use the `date` property.

Assets — typed records

Assets ("entities"/"things" in platform parlance) are typed records defined by an entity flow — the flow's origin id is the asset-type reference:

Breaking in 0.7.0: what 0.6.0 called assets.search() — the v3 free-text search — is now assets.quickSearch(). assets.search() / useAssetSearch are the paged v4 index search below: filter is required and the hook is an infinite query.

Also: asset search now asks the host for the 'asset' v4 client rather than 'config'. Inside a Rise-X shell this is transparent. If you pass your own apiV4 handler to createMockShell and switch on the key strictly, add an 'asset' case, or it will stop matching.

const types = await assets.types(); // asset types in the current ecosystem
const supplier = types.find((t) => t.entityType === 'Supplier')!;

// v3 free-text search within a type — fuzzy, but slow on large types.
const hits = await assets.quickSearch({ type: 'Supplier', search: 'acme', dataPaths: ['$.name'] });

// Full detail: data document, relationships, edit draft, permissions flags.
const detail = await assets.get(hits[0].id);

// Paginated listing — same `properties` data-shaping as work.iterate.
for await (const row of assets.iterate({ type: supplier, properties: { rating: '$.supplier.rating' } })) {
  console.log(row.displayName, row.data?.rating);
}

// Follow flow-configured relationships (targets default to assets; pass
// targetDataType: ASSET_TARGET_DATA_TYPES.work for related work items).
const related = await assets.listRelated({ assetId: detail.id, relationships: ['contracts'] });

// Create/edit go through a draft work item (the platform's edit model):
// fill it with work.patchData(), save it with work.submit().
const draft = await assets.create({ type: supplier });
await work.patchData(draft.id, { originId: draft.flowOriginId!, path: '$.supplier.name', value: 'Acme' });
const submitAction = draft.actions[0];
await work.submit({ workId: draft.id, actionName: submitAction.eventName ?? submitAction.name! });

const editDraft = await assets.startEdit({ assetId: detail.id, flowOriginId: detail.flowOriginId! });
await assets.clone(detail.id);
await assets.delete(detail.id);

assets.search() is the v4 index behind the same domain, and takes the same filter grammar as work.search(). Prefer it over quickSearch()/list() for listings: it filters and sorts on any whitelisted field or data.* path, the platform's own status and timestamps arrive on the row, and it is the indexed replacement for the timeout-prone v3 field search.

The same mandatory flowOriginId pin applies — see work.search() above. Source it from assets.types() (type.flow.flowOriginId), never from flows.list(): that lists work flows only, so its ids match nothing here.

const page = await assets.search({
  filter: {
    and: [
      // Required, always. `in` with several origin ids works too.
      { field: 'flowOriginId', operator: 'equals', values: [supplier.flow!.flowOriginId] },
      // Asset `status` values are Open/Closed/Deleted — a narrower vocabulary
      // than work's, which also has Completed/Ok.
      { field: 'status', operator: 'equals', values: ['Open'] },
      { field: 'displayName', operator: 'contains', values: ['acme'] },
    ],
  },
  // Projection. A `data.*` path must exist in the pinned flow's data schema or
  // the request 400s naming it; values arrive under `row.data` with the
  // `data.` prefix stripped (`data.supplier.rating` → `row.data.supplier.rating`).
  // Bare `data` skips the schema check and returns the whole document.
  fields: ['status', 'code', 'statusDisplay', 'data.supplier.rating'],
  sort: [{ field: 'created', direction: 'desc' }],
  pageSize: 50,
});

for (const row of page.items) {
  console.log(row.code, row.status, row.created, row.data?.supplier);
}
// Same page envelope as work.search — hasMore drives the next page. `id` and
// `status` come back on every row even when `fields` omits them; the other
// scalars are present by default but drop out once `fields` is set.

Agents — configurable AI

CRUD over the agent config registry (/api/v4/config/agent, rise-x-api) plus streaming runs through the rise-x-ai gateway:

const { items } = await agents.list({ search: 'support' });
const agent = await agents.create({
  name: 'Platform Helper',
  model: 'gpt-5.6-luna',
  systemPrompt: 'You help operators work with Rise-X flows.',
  mcpServers: [
    {
      name: 'rise-x',
      url: 'https://mcp.rise-x.io/mcp',
      transport: 'Http',
      authType: 'CallerToken', // runtime forwards the signed-in user's JWT
    },
  ],
});

// Streaming a reply — the easy path. streamAgentReply() runs the loop AND the
// delta accumulation for you and yields a ready-to-render snapshot each time
// the text grows, an error arrives, or the stream ends. Wrap either agents.run
// or chat.send.
import { streamAgentReply, collectAgentReply } from '@rise-x/apps-sdk/connectors';

try {
  for await (const s of streamAgentReply(
    agents.run({ agentId: agent.id, message: 'Open risks?', useHistory: false }),
  )) {
    setReply(s.text);                            // already accumulated — just render it
    // Report once, when the stream ends: the agent's error message if there was
    // one, otherwise a non-COMPLETED terminal reason.
    if (s.done) {
      if (s.error) showError(s.error);
      else if (s.reason !== 'COMPLETED') showError(`run ended: ${s.reason}`);
    }
  }
} catch (err) {
  // Transport / HTTP / abort failures (e.g. the AI gateway being down) THROW —
  // they are not delivered as s.error. Catch them here.
  showError(String(err)); // ConnectorError, e.g. code 'AI_UNAVAILABLE'
}

// Not streaming the UI? Await the whole answer in one call. Same rule: mid-stream
// agent errors come back as `error`; transport/abort failures throw — so catch.
try {
  const { text, reason, error } = await collectAgentReply(
    agents.run({ agentId: agent.id, message: 'Open risks?', useHistory: false }),
  );
} catch (err) {
  showError(String(err));
}

// Multi-turn chat — memory is server-persisted in Mongo (ZDR-safe, no OpenAI
// retention cliff). createChat() captures the chat_id from the first turn's
// end_of_stream and passes it back on every subsequent send().
const chat = agents.createChat({ agentId: agent.id });
for await (const s of streamAgentReply(chat.send('Summarize the open risks'))) setReply(s.text);
for await (const s of streamAgentReply(chat.send('Now only the high-severity ones'))) setReply(s.text); // remembers
const chatId = chat.chatId; // stable server id — persist it to reopen later

Chats are listable, reopenable, renamable, and deletable — the transcript (including tool-call items) is owned by the platform and read back for replay:

// List the signed-in user's chats (newest first), optionally per agent.
const { items } = await agents.listChats({ agentId: agent.id });
const one = await agents.getChat(items[0].id);

// Reopen a stored chat and continue it — memory is carried server-side.
const resumed = agents.createChat({ agentId: agent.id, chatId: one.id });
for await (const s of streamAgentReply(resumed.send('and the medium ones?'))) setReply(s.text);

// Render the full transcript (paged by seq; tool calls included).
const page = await agents.getChatMessages(one.id, { pageSize: 50 });
for (const m of page.items) renderMessage(m); // m.itemType: 'Message' | 'ToolCall' | 'ToolResult' | 'Reasoning'
// page.nextSeq → pass as fromSeq to page forward.

await agents.renameChat(one.id, 'Q3 risk review');
await agents.deleteChat(one.id); // removes the chat and its messages

Notes:

  • Streaming contract. agents.run/chat.send yield raw AgentRunEvents where assistant text arrives as agent_event frames (event_type: 'MESSAGE') whose text is a per-token delta, never the cumulative string. Prefer streamAgentReply() (yields accumulated AgentReplyState snapshots) or collectAgentReply() (awaits the final state) so you never hand-roll that accumulation. For finer control, the frame readers agentMessageText / agentEndReason / agentErrorText decode a single event, and the typed views (AgentEventData, AgentEndData, AgentErrorData) are exported for reasoning / tool-call / handoff frames.
  • agents.run/createChat need shell bridge v3 (getAi) and a deployment with DIANA_AI_API_ENDPOINT configured — otherwise they throw ConnectorError('SHELL_TOO_OLD' | 'AI_UNAVAILABLE').
  • Memory contract: clients only ever handle chatId. useHistory defaults to true; useHistory:false runs one-shot and persists nothing. Passing a chatId together with useHistory:false is illegal → INVALID_ARG (mirrors the gateway's 400) and makes no network call.
  • listChats/getChat/renameChat/deleteChat/getChatMessages hit the server chat store via the same config v4 client (/api/v4/ai/agent-chat).
  • Stored MCP apiKeys are redacted to "***" on read; agents.update() strips the sentinel so read-modify-write can't clobber the stored secret.

Errors

Every connector failure is normalized to ConnectorError with a code:

| Code | Meaning | | --- | --- | | SHELL_UNAVAILABLE | not running inside the shell (or the standalone mock has no api/apiV4 handler) | | SHELL_TOO_OLD | host bridge predates a required capability (e.g. getAi) | | AI_UNAVAILABLE | shell present but no AI gateway configured | | HTTP_ERROR / NOT_FOUND | non-2xx response (status populated) | | NETWORK_ERROR | request never got a response | | ABORTED | AbortSignal fired | | PARSE_ERROR | malformed SSE/JSON payload | | INVALID_ARG | caller-supplied argument can't be used, no request was made (e.g. an AssetType without a defining flow) |

Standalone dev

The default mock shell throws on API access. Point it at a backend to use connectors outside the shell:

import axios from 'axios';
import { createMockShell } from '@rise-x/apps-sdk';

const devAxios = axios.create({
  baseURL: 'http://localhost:50061',
  headers: { Authorization: token, Environment: 'dev' },
});

window.__DIANA_SHELL__ = createMockShell({
  api: () => devAxios,
  apiV4: (_name) => devAxios, // one dev backend serves every V4ApiKey
  ai: { baseUrl: 'http://localhost:50062', getHeaders: () => ({ Authorization: token, Ecosystem: 'dev' }) },
});

Without a backend, seed fixtures instead so data-backed screens render their real content paths under pnpm start — otherwise standalone dev only ever exercises the empty/loading/error states, and a rendering bug in the content path is invisible until the app is deployed into a host:

window.__DIANA_SHELL__ = createMockShell({
  fixtures: {
    // Keyed by flowOriginId; '*' serves any flow. Paged with the request's
    // skip/limit, so pagination and work.iterate() work as they would live.
    workRows: { '*': [{ id: 'w1', displayName: 'Monthly Price Update', workCode: 'WPL-2026-884' }] },
    // Both search fixtures are served only when the request pins flowOriginId,
    // exactly as the live endpoints behave — an unpinned search throws here too.
    workSearch: [{ id: 'w1', workCode: 'WPL-2026-884', status: 'Open', created: '2026-08-01T00:00:00Z' }],
    workDetail: { w1: { id: 'w1', displayName: 'Monthly Price Update', actions: [] } },
    workData: { w1: { pricing: { rate: 1.42 } } },
    assetTypes: [{ id: 't1', entityType: 'Pump', flow: { flowOriginId: 'origin-a' } }],
    assetRows: { '*': [{ id: 'a1', displayName: 'Pump 1' }] },
    assetSearch: [{ id: 'a1', displayName: 'Pump 1', code: 'PMP-001', status: 'Open' }],
    assetDetail: { a1: { id: 'a1', displayName: 'Pump 1' } },
  },
});

Only reads are served, and only the ones seeded: an unseeded call throws SHELL_UNAVAILABLE naming the fixture key that would answer it, and writes always throw. Both are deliberate — a fixture that returned empty data or pretended to persist would make a broken app look like a working one.

Seed real-world-shaped data, not Item 1/foo. Fixtures are only worth the effort if they exercise what live data will: real field names from the flow's schema, plausible lengths (a supplier name that wraps, a code that doesn't), the enum values the server actually returns (status: 'Open', not 'active'), ISO timestamps, and enough rows to page. Placeholder values hide exactly the bugs standalone dev exists to catch — truncation, overflow, empty optional fields, date formatting. Copy a few rows from the real ecosystem (via the search endpoints or the Rise-X MCP) when you can.

Query layer (react-query)

@rise-x/apps-sdk/query wraps the connectors in a @tanstack/react-query v5 layer: request dedupe, caching, background refetch, and cancellation for free. react-query is an optional peer — apps that never import /query don't need it installed.

import { useFlows, useWorkRows, useSubmitWork, dedupeRows } from '@rise-x/apps-sdk/query';

function MyPanel({ flowOriginId }: { flowOriginId: string }) {
  const { data: flows, error, isFetching } = useFlows();
  const rows = useWorkRows({ flow: flowOriginId, pageSize: 50 });
  const submit = useSubmitWork(); // invalidates the right caches on success

  const items = dedupeRows(rows.data); // flatten infinite pages, drop repeats
  // …
}

Zero config — do not mount a provider. The hooks resolve their QueryClient from React context: federated apps get a per-app client the shell mounts around them (isolated from the shell's own persisted cache and from other apps), and everywhere else — standalone dev, older shells — the hooks fall back to a per-bundle client automatically. The one requirement: react-query must be a share so the shell's context can cross the federation boundary — the preset does this for you:

shared: {
  // keep the bundled fallback (no `import: false`) — see the scaffold comment
  '@tanstack/react-query': { singleton: true, requiredVersion: '^5.0.0' },
}

Using react-query directly? Wrap the app in AppQueryProvider. The SDK hooks pass their client explicitly, so they never touch context — but plain react-query APIs do, and standalone dev has no provider for them to find: useQuery(flowQueries.list(args)), useQueryClient() and the devtools all throw "No QueryClient set". AppQueryProvider publishes the resolved client, so it is safe in both worlds — inside a host it re-publishes the very client the shell mounted (nothing shadowed, cache still shell-managed), and standalone it publishes the per-bundle fallback. Mounting a client of your own is the thing that breaks shell-managed caching:

import { AppQueryProvider } from '@rise-x/apps-sdk/query';

export default function App() {
  return (
    <AppQueryProvider>
      <MyScreens />
    </AppQueryProvider>
  );
}

Read hooks (all take an optional trailing SdkQueryOptionsenabled, staleTime, retry, …):

| Domain | Hooks | | --- | --- | | flows | useFlows, useFlow, useFlowConfig, useFlowLayout, useFlowTask | | work | useWork, useWorkData, useWorkRows (infinite), useWorkSearch (infinite), useRelatedWork, useWorkAudit | | assets | useAssetTypes, useAsset, useAssetSearch (infinite), useAssetQuickSearch, useAssetRows (infinite), useRelatedAssets | | agents | useAgents, useAgent, useAgentChats, useAgentChat, useAgentChatMessages |

Mutation hooks compose your onSuccess with built-in invalidation (e.g. useSubmitWork refreshes the work detail/data/audit, row listings, and asset scope): useStartWork, usePatchWorkData, useSubmitWork, useDeleteWork, useCreateAsset, useStartEditAsset, useCloneAsset, useDeleteAsset, useCreateAgent, useUpdateAgent, useDeleteAgent, useRenameAgentChat, useDeleteAgentChat.

Details worth knowing:

  • Keys are environment-scoped. Every key is ['rise-apps-sdk', envId, …] — switching ecosystem refetches automatically and the old environment's entries age out. queryKeys is exported for manual invalidation.

    Its prefix shapes take only envId (queryKeys.work.searchAll(envId), allRows, relatedAll, …) and are the reliable way to invalidate a whole listing. For a single entry, don't rebuild the args by hand: every args-carrying key normalises its input first, and each domain does it differently — search keys off the request body via searchCacheShape, while work.rows and assets.rows resolve a flow ref and an asset-type ref through helpers that aren't part of the public API. Read the key off the factory instead, which is always the one the hook used:

    queryClient.invalidateQueries({ queryKey: workQueries.search(args).queryKey });
  • Defaults: staleTime 30 s, gcTime 5 min, no refetch-on-focus, up to 2 retries that skip terminal ConnectorError codes (4xx, NOT_FOUND, ABORTED, …). Override per call or via createAppQueryClient(overrides).

  • Errors are ConnectorErrorerror.code narrows exactly like the connector table above.

  • Full react-query power (select, suspense, prefetch): use the queryOptions factories directly — useQuery(flowQueries.list(args)), queryClient.prefetchQuery(workQueries.detail(id)). useAppQueryClient() returns the same client the hooks use.

  • Raw connector writes bypass the invalidation map — after one, call invalidateAppSdkQueries(useAppQueryClient()).

  • Out of scope: the async iterators (work.iterate/assets.iterate — use the infinite hooks instead) and the SSE streams (agents.run, chat send) stay on the connectors.

Shell-side (host) wiring

import { axios } from '@diana/core';
import { installShellBridge } from '@rise-x/apps-sdk/shell';

installShellBridge({
  getApi: () => axios.api(),
  getApiV4: (name) => axios.apiV4(name),
});

getApiV4 takes a V4ApiKey ('apps' | 'work' | 'config' | 'attachment' | 'asset') — guests choose which named v4 instance they want and the host forwards the key to @diana/core's axios.apiV4(key). The host registers each key it exposes via setupApiV4(key, { baseURL }) before installing the bridge.

'asset' is new in 0.7.0 and is the one key a host may leave unregistered: hosts built before it either return nothing or throw for that key, so assets.search() falls back to the 'config' client, which is where the endpoint was routed previously. Register it (pointing at DIANA_API_V4_ASSET, defaulting to the v4 endpoint) to route asset search independently.

ShellBridgeKeeper (React component in the shell) pushes the current user, environment, and router navigate into the bridge via getMutableShell — also exported from @rise-x/apps-sdk/shell.

The shell's RemoteAppLoader additionally wraps every mounted app in a per-app QueryClient (built with createAppQueryClient from @rise-x/apps-sdk/query), which is what the SDK's query hooks pick up through context in federated apps.

Build config (@rise-x/apps-sdk/rsbuild)

Apps build with Rsbuild, and the Module Federation contract with the shell lives in this preset rather than in each app's config.

// rsbuild.config.mts
import { defineConfig } from '@rsbuild/core';
import { defineAppConfig } from '@rise-x/apps-sdk/rsbuild';
import pkg from './package.json';

export default defineConfig(({ envMode }) =>
  defineAppConfig({ pkg, port: 5102, standalone: envMode === 'standalone' }),
);
pnpm rsbuild build                  # federated bundle -> dist/
pnpm rsbuild dev                    # federated, served for a host to load
pnpm rsbuild dev --env-mode standalone   # standalone, no shell -> dist-local/

Requires all three of @rsbuild/core, @rsbuild/plugin-react and @rsbuild/plugin-type-check in the app — the preset imports each at module level, so a missing one fails at config load with MODULE_NOT_FOUND. They are optional peers here, so hosts that never import this subpath get no unmet-peer noise; the scaffolder puts all three in a new app's devDependencies.

| Option | Required | Purpose | |---|---|---| | pkg | yes | the app's package.json. name derives the MF scope (@rise-x-apps/vendor-hubapp_vendor_hub); version is baked as __APP_VERSION__ | | port | yes | dev-server port. Must be unique across apps | | standalone | no | no shell, no MF, React bundled in | | exposes | no | extra MF exposes, merged over ./App + ./lifecycle | | define | no | extra source.define entries, merged into the preset's own |

__APP_VERSION__ is a define, so TypeScript needs to be told it exists. Add declare const __APP_VERSION__: string; to the app if you read it.

The converse is the trap define exists for: a declare const in the app's global.d.ts makes TypeScript accept the identifier whether or not the build substitutes it. Declare one without passing it here and the app compiles clean, then throws ReferenceError on load with nothing in the build output pointing at the cause.

Values are raw code, not data — the same contract as webpack's DefinePlugin — so a string arrives quoted. And because the option takes a fixed object, a value that depends on the build has to be derived in the defineConfig callback:

export default defineConfig(({ envMode }) =>
  defineAppConfig({
    pkg,
    port: 5101,
    standalone: envMode === 'standalone',
    define: {
      __USE_SAMPLE_DATA__: String(envMode !== 'production'),
      __API_LABEL__: JSON.stringify(envMode ?? 'development'),
    },
  }),
);

Passing __APP_VERSION__ throws: it is derived from pkg.version so it always matches the deployed manifest.

What the federated branch sets, and why it matters:

  • ModuleFederationPluginV1, never the enhanced plugin — V1 emits the container shape (window[scope].init/get) that both hosts' runtime loaders speak.
  • output.uniqueName = scope via tools.rspack. Rsbuild has no output.uniqueName of its own, and it names the chunk-loading global — left at the default it collides with the host's.
  • React, its JSX runtimes and @rise-x/ui are consume-only (import: false): the host provides the single copy. @tanstack/react-query deliberately keeps its bundled fallback, so an app still works on a host that does not share it.
  • React is shared as ^18.0.0 || ^19.0.0, because the two hosts are on different majors (ClientApp 18, Shell 19) and one bundle must load on both. That is a build-level permission, not a licence to use React 19 APIs — write app code against the React 18-compatible API surface, since ClientApp is still the production host.
  • Flat dist/remoteEntry.js beside its chunks and every emitted asset, because tools/apps-dev-server probes dist/remoteEntry.js and the deploy zips the contents of dist/. Rsbuild would otherwise route assets into static/<kind>/; the preset flattens all of them, which is also what the webpack configs it replaces did.
  • assetPrefix: 'auto' on both output and dev so one bundle works on the dev port and when the shell loads remoteEntry.js from the CDN. Rsbuild splits what webpack had as a single output.publicPath, and the dev half defaults to / — a remote served by rsbuild dev would otherwise resolve its async chunks against the host's origin.
  • Production source maps, which Rsbuild otherwise defaults off.

The preset's own standalone specs resolve @rise-x/apps-sdk/ui/standalone, so run pnpm build in this package before pnpm test on a clean checkout (CI already builds first).

Set APP_SCOPE to override the derived scope. That is only for registering two builds of one app side by side (an old and a redesigned version, to compare them) — two registrations may not share a scope, because the shell caches loaded remotes by URL and the second remoteEntry.js would overwrite the first's global. Leave it unset for normal releases.

Local development against the SDK

In-repo consumers (ClientApp, apps/*) declare "@rise-x/apps-sdk": "workspace:*", so pnpm links them straight to packages/apps-sdk — no publish or link step.

Live loop (rebuilds build/ on save; consumers pick it up on their next compile):

cd packages/apps-sdk && pnpm start:package

For an app developed outside this repo, install the published SDK package:

pnpm add @rise-x/apps-sdk

Publishing

Publishes to the public npm registry (registry.npmjs.org) under the rise-x org, per publishConfig in package.json. Releases are manual: run the Publish @rise-x/apps-sdk workflow (.github/workflows/publish-apps-sdk.yml) via workflow dispatch, from main or a release/* branch — it publishes the version in package.json, and fails if that version is already on the registry.

To publish manually you need an npmjs account with publish rights in the rise-x org (npm login), then:

pnpm publish --no-git-checks

The prepack hook builds @rise-x/ui and the package automatically — no need to build first. The registry and public access come from publishConfig, not CLI flags.

template/ is included via the files array so npx @rise-x/apps-sdk init works from the registry version.