@roughapp/feature
v0.8.0
Published
Readme
@roughapp/feature
@roughapp/feature is the browser SDK for embedding Rough Features in your
product. Your application defines a Surface (a place where Features can
appear) and the capabilities available there. The SDK connects to Rough,
subscribes to published Features, renders them, and opens the Builder.
The package ships as ESM with TypeScript declarations. Component styles are
included; no stylesheet import is required. The Builder bundles Source Sans Pro
for its UI (licensed under the SIL Open Font License; see FONT-LICENSE.txt in
the published package). It is browser-only: import and
start it from a client-only boundary, not during SSR. Use a modern browser and
bundler; direct <script> and CDN usage are not supported.
Pre-1.0: minor releases may contain breaking changes. Review the Feature SDK changelog when upgrading.
Prerequisites and installation
You need a Rough project ID, a signing key (keep its private key on your
server), and an authenticated backend endpoint that mints a short-lived Rough
identity token for the signed-in user. Create a project and signing key in
Rough's dashboard, and save the Project ID, signing
key ID (kid), and private key. Keep the key ID and private key on your server. Never mint tokens or
store the private key in browser code.
npm install @roughapp/feature zod@4Install zod directly for your Surface definitions. The SDK's other runtime
dependencies are installed with the package.
1. Set up server-side authentication
The browser SDK calls your endpoint for a Rough identity token whenever it needs to authenticate. Your server must verify the caller's existing app session, then sign a short-lived JWT for that user. The SDK exchanges that JWT with Rough for a session token; your server does not need to call Rough.
For example, install jose on your server (npm install jose) and add this
Express-style route after your app's authentication middleware. Adapt
request.user and the account fields to your own authentication system:
import { SignJWT, importPKCS8 } from 'jose'
// Server-only environment variables; never expose the private key to the browser.
const projectId = process.env.ROUGH_PROJECT_ID!
const keyId = process.env.ROUGH_SIGNING_KEY_ID!
const privateKeyPem = process.env.ROUGH_SIGNING_PRIVATE_KEY_PEM!
app.post('/api/rough/user-token', async (request, response) => {
const user = request.user // Set by your authentication middleware.
if (!user) return response.sendStatus(401)
const privateKey = await importPKCS8(
privateKeyPem.replaceAll('\\n', '\n'), // Handles escaped newlines in env vars.
'RS256',
)
const token = await new SignJWT({
rough: {
user: { name: user.fullName, email: user.email },
account: { id: user.organisationId, name: user.organisationName },
},
})
.setProtectedHeader({ alg: 'RS256', typ: 'JWT', kid: keyId })
.setIssuer(projectId)
.setSubject(user.id)
.setAudience('rough:session')
.setIssuedAt()
.setExpirationTime('5m')
.sign(privateKey)
response.set('Cache-Control', 'no-store').json({ token })
})Use the Project ID (not the signing key ID) as iss and as the browser
client's projectId. The kid must identify the matching signing key in
Rough. Use a stable ID for sub that matches the browser's externalUserId,
and a stable rough.account.id that matches the browser's
externalAccountId to keep accounts separate. Use the user's stable ID for
both rough.account.id and externalAccountId when each user has a private
space, or a workspace ID when its members should share Features. A constant such as 'default' puts all
users in one Rough account; use it only when that sharing is intentional. The
required rough.user.name describes the user; rough.user.email and
rough.account.name are optional (Rough uses the account ID as its name if
omitted). Tokens must use RS256, include iat and
exp, and expire within five minutes. Never accept the user or account IDs
from the request body instead of your authenticated session. See the
getting started guide for dashboard
setup and a longer walkthrough.
2. Define a Surface
A Surface has a stable key and an ordered toolList of capabilities a Feature
can use. The implementations run in your application; Rough receives the
schemas and samples, not the implementation code.
import { defineRoughSurface, Query } from '@roughapp/feature'
import { z } from 'zod'
const Message = z.object({
id: z.string(),
subject: z.string(),
})
export const inboxSurface = defineRoughSurface({
key: 'inbox',
name: 'Inbox',
description: 'The signed-in user’s message inbox.',
toolList: [
new Query({
id: 'listMessages',
name: 'List messages',
description: 'Return the newest messages in the inbox.',
inputSchema: z.object({
limit: z.number().int().min(1).max(100).default(20),
}),
outputSchema: z.array(Message),
outputSample: [{ id: 'msg_1', subject: 'Welcome' }],
implementation: async ({ limit }) => {
const response = await fetch(`/api/messages?limit=${limit}`)
if (!response.ok) throw new Error('Unable to load messages')
return response.json()
},
}),
],
})Use Query for reads, Mutation for writes, and Subscription for ongoing
updates. Queries and Mutations receive parsed input and return a promise for
an output. A Subscription implementation receives an output callback and then
parsed input; it returns a promise for an unsubscribe function. Inputs and
outputs are validated against their Zod schemas at runtime. Provide
representative outputSample values without real customer data or secrets:
Builder previews use samples, not your live implementations.
defineRoughSurface() is a typed identity helper: it returns the supplied
object without runtime validation, freezing, or copying. Treat the definition
and its toolList as immutable. It contains no project or connection state,
so it can be reused with different clients. Use a consistent Surface definition
for a given key across integrations. Changing or removing capabilities may
break already-built Features.
3. Start a client
Create one client for the Rough project, account, and signed-in user at a
browser route, provider, or similar owner. Pass a stable externalUserId from
your app for that user (the same ID your backend uses as the identity token's
subject), and a stable externalAccountId (the same ID as the identity token's
rough.account.id). fetchUserToken calls your authenticated backend and
returns the identity token string (not a session object).
import { startRoughClient } from '@roughapp/feature'
const roughClient = startRoughClient({
projectId: 'proj_123',
externalUserId: signedInUser.id, // Matches the token's `sub`.
externalAccountId: signedInUser.organisationId, // Matches `rough.account.id`.
fetchUserToken: async () => {
const response = await fetch('/api/rough/user-token', {
method: 'POST',
credentials: 'include',
})
if (!response.ok) throw new Error('Unable to authenticate with Rough')
const { token } = (await response.json()) as { token: string }
return token
},
onError: (error) => console.error('Rough authentication failed', error),
})startRoughClient() returns synchronously; the client starts its metadata sync
in the background and authenticates as needed. Handle authentication errors
with onError above and errors from asynchronous operations with catch.
Use a fresh client when switching users, accounts, or projects; externalUserId and
externalAccountId keep each user's account-specific browser cache separate.
baseUrl and mediaBaseUrl are optional overrides for the Rough gateway and
CDN URLs.
4. Subscribe and mount published Features
Your application chooses where to show Features. onChange receives an initial
list (possibly empty), then updates when the published list changes. Each item
has id, name, surfaceKey, and publishedFeatureBuildId. Put an element with
id="rough-slot" in your page; this example mounts each published Feature in
its own container:
import { mountRoughFeature, subscribeToRoughFeatures } from '@roughapp/feature'
import type { RoughFeatureMount } from '@roughapp/feature'
const list = document.querySelector<HTMLElement>('#rough-slot')!
const mounts = new Map<string, { container: HTMLElement; mount: RoughFeatureMount }>()
const unsubscribe = subscribeToRoughFeatures({
client: roughClient,
surface: inboxSurface,
onChange(features) {
const wanted = new Set(features.map((feature) => feature.id))
for (const [id, { container, mount }] of mounts) {
if (wanted.has(id)) continue
mounts.delete(id)
mount.unmount()
container.remove()
}
for (const feature of features) {
if (mounts.has(feature.id)) continue
const container = document.createElement('div')
list.append(container)
const mount = mountRoughFeature(container, {
client: roughClient,
surface: inboxSurface,
featureId: feature.id,
})
mounts.set(feature.id, { container, mount })
}
},
})
// Register this function with the owning view's teardown lifecycle.
// Do not call it immediately after subscribing.
function cleanupFeatures() {
unsubscribe()
for (const { container, mount } of mounts.values()) {
mount.unmount()
container.remove()
}
mounts.clear()
}Only published Features are listed. onChange runs synchronously with the
current local list, which may be empty while metadata sync starts, then again
when that list changes. subscribeToRoughFeatures() returns a synchronous
unsubscribe function that is safe to call more than once. It does not return a
ready promise or accept an onError callback; authentication errors are handled
by the client's onError callback.
In a framework, put the subscription and cleanup in the view's lifecycle rather than running the teardown immediately after startup. This keeps the example small; adapt the container management to your framework.
mountRoughFeature() takes a host-owned HTMLElement first and Feature props
second. It renders inside a Shadow DOM and returns a RoughFeatureMount with
update(partialProps) and unmount(). The public type declares both methods
as returning void; do not chain .then() on unmount(). Call it before
clearing the container; it is safe to call more than once. Use
mount.update({ isEditable: false }) (or pass other changed Feature props) to
update a mounted Feature without replacing its container. Omit buildId to
follow the currently published build: a mounted Feature updates when a new
build is published without being remounted. Pass buildId to select a specific
build, or update it to undefined to resume following the published build. isEditable defaults to true; setting it to
false hides Rough's edit control, not an authorization boundary. The mount API
does not accept a publication callback; observe publication changes through
subscribeToRoughFeatures() instead.
By default, the Feature's application state is kept in the host browser's
local storage, keyed only by Feature ID—not by the signed-in user or account.
Users opening the same Feature in the same browser origin share that stored
state, even after switching users. This is separate from the user/account-scoped
metadata cache. For user/account isolation, shared state across browsers, or
your own persistence, pass a datastore implementing RoughFrameDatastore:
import type { JsonValue, RoughFrameDatastore } from '@roughapp/feature'
let state: JsonValue | undefined
const listeners = new Set<(state: JsonValue) => void>()
const datastore: RoughFrameDatastore = {
get: () => state, // JSON value, or undefined when no state exists
set: (next) => {
state = next
for (const listener of listeners) listener(next)
},
addListener: (callback) => {
listeners.add(callback)
return () => listeners.delete(callback)
},
}
// Pass `datastore` in the second argument to mountRoughFeature(),
// e.g. alongside `client`, `surface`, and `featureId` above.The in-memory state above is illustrative; back it with your own persistence
for shared or durable state. set may return a promise, but the SDK does not
await it; handle persistence failures inside your implementation. Keep the datastore
object's identity stable while mounted, use separate state per Feature (and
per user/account when isolation is required), and notify listeners when state
changes (including changes received from other viewers). Feature state is
separate from Rough's synchronized Feature metadata.
Create and edit Features
Create a blank Feature before opening its Builder. openRoughBuilder() returns
a Promise<RoughBuilderHandle>; the handle exposes only close(), not an
EventTarget or publication events. Use subscribeToRoughFeatures() to update
host-owned metadata or layout when a Feature is published.
import { createRoughFeature, openRoughBuilder } from '@roughapp/feature'
const feature = await createRoughFeature({
client: roughClient,
surface: inboxSurface,
name: 'Inbox summary', // Optional; defaults to an empty string.
})
const builder = await openRoughBuilder({
client: roughClient,
surface: inboxSurface,
featureId: feature.id,
theme: 'dark', // Optional; defaults to 'light'.
})
// When the host needs to close it programmatically (or during view teardown):
// builder.close()createRoughFeature() creates an unpublished Feature in the local metadata
store and queues synchronization with Rough; resolution does not mean the
server has acknowledged it. Closing the Builder without publishing does not
delete it. To edit an existing Feature, pass its ID to openRoughBuilder()
without creating another. Users can also close the
Builder from its UI; calling close() again is safe. Its public return type is
void. The theme affects the Builder UI, not the generated Feature iframe.
Client lifetime
Share a client across integrations for the same project, account, and signed-in user. Destroy it when its owner goes away or before switching user, account, or project, after tearing down the view's mounted Features:
await roughClient.destroy()destroy() closes the client's subscriptions and Builders and waits for their
cleanup. It is safe to call more than once. Unmount rendered Features as part
of your view's teardown. In framework hooks that cannot await cleanup, use
void roughClient.destroy(); tests and controlled transitions should await it.
