@w6w/embed
v0.2.1
Published
Embed w6w's managed editor and providers in a host React app.
Readme
@w6w/embed
Embed w6w in a host React app: one provider that wires the w6w API client, @w6w/ui's components
and an identity-scoped query cache to your own sign-in, and a managed workflow editor
(<WorkflowEditor>) you mount as a route.
License: FSL-1.1-ALv2 (Functional Source License). This package is
source-available, not open source: the licence's Competing-Use clause applies, and each version
converts to Apache-2.0 after two years. @w6w/sdk, @w6w/react and @w6w/ui, which it depends on,
stay MIT.
Install
npm i @w6w/embed react react-domThis is the only w6w package most hosts install. @w6w/sdk, @w6w/react, @w6w/ui and
@tanstack/react-query come with it as dependencies; install @w6w/ui yourself too (^0.2.0, so
there is one copy) only if you write depth 2 below, since a strict resolver such as pnpm does not let
host code import a transitive dependency; react and react-dom (18 or later) are
yours. Import the one stylesheet once, at your app's root:
import "@w6w/embed/styles.css"; // @w6w/ui's styles, React Flow base includedEntries
Every import path the published package exposes. Each carries "use client", so a Next.js server
page can import it.
| Import | What it holds |
| --- | --- |
| @w6w/embed | W6WEmbedProvider, useEmbedStatus, useEmbedProject, EmbedAuthError. |
| @w6w/embed/editor | WorkflowEditor alone, loaded lazily. |
| @w6w/embed/editors | The callable editors: WorkflowEditor, FunctionEditor, EndpointEditor, DocumentEditor. |
| @w6w/embed/lists | The connected lists and detail views: FunctionList, EndpointList, WorkflowList, ReliabilityBoard, ActivitySummary, RunHistory, DocumentList, ServiceDetail, ConnectionList, ConnectionDetail. |
| @w6w/embed/forms | The configuration forms: CreateFunctionModal, CreateEndpointModal, VarsManager, SecretsManager. |
| @w6w/embed/rail | CallableWorkspace and CallableRail, to place the editor's rail yourself. |
| @w6w/embed/repos | The data hooks the components read through (useFunctionsQuery, useAppActionsQuery, ...), on the provider's one cache. |
| @w6w/embed/draft | The headless draft lifecycle (autosave, flush on hide, the stale-write token). No UI. |
| @w6w/embed/lib | Pure helpers the editor shares with Studio. |
| @w6w/embed/styles.css | The one stylesheet: @w6w/ui's styles, React Flow base included. |
Three depths
Use as much of the package as you need.
1. Drop in the component. It fetches, renders and writes on its own:
import { FunctionList } from "@w6w/embed/lists";
<FunctionList onOpen={(id) => router.push(`/functions/${id}`)} />;2. Keep @w6w/ui's presentational component and feed it from an embed hook. You own the rows:
import { CallableList } from "@w6w/ui";
import { useFunctionsQuery } from "@w6w/embed/repos";
function MyFunctions() {
const { data = [], isLoading } = useFunctionsQuery();
return (
<CallableList
title="Functions"
loading={isLoading}
rows={data.map((f) => ({
id: f.id,
title: f.displayName || f.key,
archived: f.archivedAt != null,
}))}
onOpen={(id) => router.push(`/functions/${id}`)}
/>
);
}3. Use the hook alone and draw everything yourself:
import { useFunctionsQuery } from "@w6w/embed/repos";
const { data } = useFunctionsQuery();
return <ul>{data?.map((f) => <li key={f.id}>{f.displayName || f.key}</li>)}</ul>;Depth 2 imports @w6w/ui directly, so your host must list it (see Install); depth 3 does not. All three read
the provider's one cache (see One cache).
The provider
import { W6WEmbedProvider } from "@w6w/embed";
function Host({ user }: { user: { id: string; accountId: string } }) {
return (
<W6WEmbedProvider
apiOrigin="https://api.w6w.io"
identityKey={`${user.id}:${user.accountId}`}
getSession={() => fetch("/api/w6w-session", { method: "POST" }).then((r) => r.json())} // -> {token, expiresAt}
project="proj_123" // optional default project, see "The project rule"
theme="dark" // optional: follow your host's theme
>
{/* @w6w/ui components, @w6w/react hooks and <WorkflowEditor> work here */}
</W6WEmbedProvider>
);
}getSession calls an endpoint on your backend that mints an account-scoped session for the
signed-in user with createEmbedSessionClient from @w6w/sdk/server; the tenant client secret
never reaches the browser. The recipe is in the guide at /guides/embed/.
identityKeynames who is signed in. When it changes, the API client and the query cache are replaced and the subtree re-mounts, so nothing cached for the previous identity is shown to the next one.- The provider owns its
QueryClient; it does not use, and does not need, one from your app. ready={false}holds every read until you have a user;projectsets the default project.project={{ use: "default" }}uses the account's default project instead of an id you know. It is looked up once per identity (oneGET /projects, the row withisDefault), never the first row of the list.useEmbedProject()returns{ project, status, error? }withstatusone of"resolving" | "ready" | "error": while resolving,projectisnulland editors stay in their "project required" state; if the lookup fails or no row is the default,statusis"error"and nothing falls back to another project. A plainproject="proj_123"is"ready"at once; omitted, it is{ project: null, status: "ready" }.identityKey={null}means nobody is signed in: the provider rendersfallback(default nothing) and mounts none of the embed, so no session is requested and no API call is made. Goingnullto an identity starts at once; going from an identity tonullfirst writes any pending draft into the account that made it, then showsfallback.useEmbedStatus()returns{ status, error }withstatus"connecting" | "ready" | "error". The session is acquired by the first request a mounted surface makes, so withouteagerthe status stays"connecting"until then.eageracquires the session when the provider mounts. A token your backend minted but the API refused (401) is"error"with anEmbedAuthError(causeis the SDKApiError) until the next acquisition starts or the identity changes; a failinggetSessionis"error"with its own error. Withproject={{ use: "default" }}the lookup itself acquires a session on mount (sogetSessionis called even withouteager), and the client is rebuilt once the project resolves, which acquires a session again: the status goesconnecting,ready,connecting.
Next.js server components
A server component cannot pass a plain function to a client component, so a getSession written
inline there does not work. Define it as an inline server action instead:
// app/page.tsx (a server component)
import { createEmbedSessionClient } from "@w6w/sdk/server";
import { W6WEmbedProvider } from "@w6w/embed";
import { currentUser } from "./your-auth"; // YOUR server-side session lookup
const sessions = createEmbedSessionClient({
baseUrl: process.env.W6W_BASE_URL!,
clientId: process.env.W6W_TENANT_CLIENT_ID!,
clientSecret: process.env.W6W_TENANT_CLIENT_SECRET!, // stays on the server
});
export default async function Page() {
const user = await currentUser(); // from your own session, on the server
if (!user) return null;
async function getSession() {
"use server";
const u = await currentUser(); // no arguments: who is signed in comes from the server
if (!u) throw new Error("unauthenticated");
const s = await sessions.create({ subject: u.id, accountRef: u.companyId });
return { token: s.token, expiresAt: s.expiresAt }; // nothing else leaves the server
}
return (
<W6WEmbedProvider
apiOrigin={process.env.W6W_BASE_URL!}
identityKey={`${user.id}:${user.companyId}`} // the same server-side user
getSession={getSession}
>
{/* a client component renders <WorkflowEditor> here */}
</W6WEmbedProvider>
);
}A server action is a public POST endpoint: anyone can call it with any arguments. So getSession
must never accept the subject or the account from its caller, and takes no arguments at all.
It reads the signed-in user from your own server-side session, mints for that user only, and
returns only { token, expiresAt }. The tenant client secret stays on the server, and
identityKey is computed from the same server-side user. In a client component, the fetch form
above stays the default.
The editor
Mount the managed editor as a route in your app. It is loaded lazily, so it adds nothing to your bundle until the route renders:
import { WorkflowEditor } from "@w6w/embed/editor";
function WorkflowRoute({ workflowId }: { workflowId: string }) {
return (
<WorkflowEditor
workflowId={workflowId}
project="proj_123" // optional when the provider has one
onNavigate={(to) => {/* route inside your app */}}
/>
);
}Props: workflowId (required), project, initialRunId, onNavigate and slots. onNavigate is
optional (default: a no-op) and hands navigation (back to the list, or to another workflow) to your
router; slots lets you place your own elements in the editor chrome; initialRunId opens the
editor on a run. The editor must render inside <W6WEmbedProvider>.
Placing the rail yourself
By default the editor renders its own right rail (rail omitted or true). Pass rail={false} to
render no rail and place it elsewhere: wrap the editor in <CallableWorkspace kind="workflow"
id={workflowId}> and put <CallableRail kind="workflow" id={workflowId} /> anywhere inside it,
such as a drawer. Both come from @w6w/embed/rail; the rail then edits the same draft the editor
saves. Without an enclosing workspace, rail={false} simply has no rail. See the
components reference.
The project rule
The editor takes its project from its project prop, else from the provider's project. Saves
never fall back to the account's default project, because a save without a project would silently
move the workflow. With neither set (or with project={null} or "", which refuses even when the
provider has one), the editor opens with saving and publishing disabled and a "project required"
message.
Autosave and switching identity
Edits autosave. When identityKey changes, a pending save is first flushed into the account it
belongs to, and only then does the provider swap to the new identity. There is no discard path: a
save that fails or times out does not block the switch, and is never retried into the new account.
Theming
The editor's chrome is styled by the same @w6w/embed/styles.css. Every rule is scoped under the
editor's root element, class w6w-embed-root, so it does not leak into your page. The palette
comes from the nearest [data-theme] ancestor: the provider's theme prop puts one around the
tree. Omit theme and the editor follows data-theme on <html> if you set it, else the OS
preference. The editor fills its parent (width and height 100%), so give its container a height.
The layout rules every embedded surface follows (it fills its parent, navigates by callback, and keeps
every class under that root) are in docs/layout.md, a repo doc not shipped in the package.
Function editor
| Component | Import | What it is |
| --- | --- | --- |
| <FunctionEditor functionId project? onNavigate? slots? rail? tools?> | @w6w/embed/editors | One Function: Interface (name, key, description), Inputs, Implementation, Test invoke, with History, a JSON toggle and Save in the header. |
Saving, the JSON toggle's Save and the rail's Error handling all write the same draft, and every
write carries the stored updatedAt. If the Function changed on the server since it was opened, the
save is refused with an inline "Reload from server" control and is never retried. rail defaults to
true; rail={false} publishes into an enclosing <CallableWorkspace kind="function" id> for the
same id (the rail's saves then use that workspace's project). project only scopes the expression
rail, since a Function belongs to the account. There is no Aliases section; add one through tools.
See the components reference.
Lists
@w6w/embed/lists holds the connected lists (FunctionList, EndpointList, WorkflowList, ReliabilityBoard, ActivitySummary, RunHistory, DocumentList, ServiceDetail, ConnectionList, ConnectionDetail). Each reads through the same provider as the editor and
writes through it, so a host mounts nothing else.
| Component | What it does |
| --- | --- |
| <FunctionList project? onOpen? getHref? onCreated? slots?>, <EndpointList>, <WorkflowList> | The account's Functions, Endpoints and the project's Workflows, each with a Show-archived toggle, New and Import, and rows that are buttons, or real links with getHref. onOpen(id, event) and onCreated(id) hand navigation to your router. Per-prop details, including EndpointList and WorkflowList, are in the components reference. |
| <ConnectionList onOpen? getHref? addLabel? slots?> | Your connections with Add, Edit and Delete. Delete asks first. Edit appears only when the API behind the provider can save a connection, which <W6WEmbedProvider> always can. getHref makes each row a real link. slots.actions replaces the Add button (pass null to remove it), slots.header and slots.empty replace the heading and the empty state. Built-in "no credential" rows have no Edit, Delete or link. |
| <ConnectionDetail connectionId onOpenAction onBack? slots?> | One connection: Test connection, Edit, the app's actions with their saved tests, and recent runs. onOpenAction(actionKey) fires for an action and onOpenAction(actionKey, savedTestId) for a saved test; onBack shows a back button when given. A missing connection shows a "not found" state. |
import { ConnectionDetail, ConnectionList } from "@w6w/embed/lists";
<ConnectionList onOpen={(id) => router.push(`/connections/${id}`)} />;
<ConnectionDetail connectionId={id} onOpenAction={(key, testId) => {/* your router */}} />;@w6w/embed/lists holds the connected list components. <ReliabilityBoard project? variant? limit?
days? showChart? getHref? onOpen? showAllHref? onShowAll? slots?> shows one project's service
reliability: it loads the project's services itself (membership first, then status in serial batches
of at most ten) and draws them with @w6w/ui's ReliabilityList. Icons come from the rows, so it
makes no app-catalog request. days defaults to 30; limit only trims what is shown. Omit project
to use the provider's; null or "" makes no request and asks for a project.
<ActivitySummary getHref? onOpen? slots?> shows the account's run statistics and recent activity:
"Today" and "Last 7 days" headline cards, "Activity over time" and "Recent activity". It is
account-wide (it takes no project), and it resolves each row's function or workflow name against
the account's own lists (the workflow list follows the client's default project when the provider
names one, so a workflow outside it stays plain text). The name is a link only when it resolves: getHref makes it an <a href>,
onOpen alone makes it a button, and with neither it stays text. A click calls
onOpen(target, event) and is never cancelled by the component. It makes two /dashboard/stats
requests on purpose: "Today" is cut at the viewer's local midnight (from, bucket=day), and the
server only groups by day or week, cutting at midnight in the database's timezone, so one
request cannot give a local day. <RunHistory scope? showKind? filters? onFiltersChange?
onOpenInEditor?> shows the execution history for the provider's project (or the scope you pass);
omit filters and it keeps them itself, pass them and you own them. See the
components reference.
<ServiceDetail appId project? days? backHref? onBack? slots?> shows one service's reliability
drill-down: health, error mix, the API's own status, daily history and recent calls. It makes one
single-service status request (apps=<appId>, never the whole board) plus one call-log request.
days defaults to 30; omit project to use
the provider's, and with none it makes no request. When the service has no row for the project it
shows a not-found card whose back link is an <a href> with backHref, a <button> with only
onBack, and absent with neither; a click calls onBack and is never cancelled.
Configuration forms
@w6w/embed/forms holds the connected forms (VarsManager, SecretsManager, and the CreateFunctionModal / CreateEndpointModal dialogs the lists open). Each is one self-contained element (root class
w6w-embed-root) that reads and writes through the provider's client.
| Component | Scope | Props |
| --- | --- | --- |
| <VarsManager> | one project | project?: string \| null, slots? |
| <SecretsManager> | the whole account (no project) | slots? |
VarsManagerlists, adds, edits and deletes typed variables (string,number,boolean,json). Omitprojectto use the<W6WEmbedProvider project>.nullor""means no project: nothing is requested and "Add" is disabled. Every request names the project it acts on.SecretsManageris write-only. A secret's value is typed into a form and sent; it is never shown again, never held in the query cache, and never passed to a prop or callback. Rotating with a blank value changes only the description. Secrets belong to the account, so the component takes no project.slots:{ header?, actions?, empty? }. Leave a slot out for the default (the heading, the "+ Add ..." button, "No ... yet."), passnullfor nothing, or pass a node.actionsalso accepts a function,({ openCreate, canCreate }) => ReactNode, so a host that supplies its own button can still open the create form.
Documents
| Component | Import | What it is |
| --- | --- | --- |
| <DocumentList> | @w6w/embed/lists | A project's documents: add, edit in place, delete. |
| <DocumentEditor documentId> | @w6w/embed/editors | One document: its key and content, with Edit and Delete. |
Both take project (omitted: the provider's project; null or "": nothing is read and writes
refuse) and slots (omitted: the default, null: nothing, a node: replaces). Documents the server
shares in from a repository show no Edit or Delete. The embed has no repository sync and no "synced
from" markers; a host that wants them adds them through the list's row slots (lead,
sharedActions, footer). Details are in the components reference.
Endpoint editor
| Component | Import | What it is |
| --- | --- | --- |
| <EndpointEditor endpointId project? onNavigate? slots? rail? tools?> | @w6w/embed/editors | One Endpoint: Identity, the callable URL, Input, Target, Security, Test invoke, History and a JSON toggle, with a Save at the top and the bottom. |
Every write is one save carrying the stored updatedAt; if someone else saved first, the editor says
so inline with a Reload and never retries. Secret fields are write-only: a stored secret shows
"stored — type to replace" and is kept unless you type a new one. There is no Aliases section.
rail={false} inside a <CallableWorkspace> for the same id publishes into that workspace. Details
are in the components reference.
Console exclusions
Your host code makes no client.console call. The package uses a public @w6w/sdk operation
wherever one exists, and every remaining client.console or raw request() site is listed here
with its reason.
| File | Call | Reason |
| --- | --- | --- |
| src/repos/workflows.ts | console.workflows.listRuns, console.workflows.getRun + pollRunToTerminal | no public run-list or run-get operation; the run poll is what the Run feature needs |
| src/repos/workflows.ts | console.workflows.upsert | creating a workflow in a named project has no public operation |
| src/lib/history-query.ts | console.workflows.getRun; raw GET /history, GET /history/stats, GET /invocations/:id | no public history or invocation operation |
| src/repos/step-tests.ts | console.stepTests.list, .save, .recordRun | no public operation |
| src/repos/vault.ts | console.vault.list, .seal, .create, .update, .delete | vault is outside the public contract; used by SecretsManager (@w6w/embed/forms) to list, add, rotate and delete a secret, and to seal one from the expression editor |
| src/repos/documents.ts | raw POST /documents/:id/sync | no public operation |
| src/repos/spec-import.ts | raw POST /spec/import | no public operation (Settings import) |
| src/repos/vars.ts | raw GET /vars, POST /vars, PATCH /vars/:id, DELETE /vars/:id | the SDK's vars methods take no options argument, so they cannot send ?project=; VarsManager (@w6w/embed/forms) needs it on every call |
| src/repos/versions.ts | raw GET /:c/:id/versions, GET /:c/:id/versions/:v, POST /:c/:id/publish, POST /:c/:id/:x, POST /:c/:id/versions/:v/revert, POST /:c/:id/versions/:v/:x | the endpoint arm has no public version operation; the function arm of invoke-version keeps the raw invocation frame, which functions.runVersion unwraps |
| src/repos/callables.ts | console.functions.list, .get, .upsert; console.endpoints.list, .get, .upsert; console.workflows.get, .upsert, .archive, .delete; the function and endpoint archive (POST /:c/:id/archive) and delete (DELETE /:c/:id) go through the client's plain request() with a computed path | endpoints have no public list, read or write operation (the public SDK exposes only endpoints.run), and no public archive or delete; the function arm is not a missing-operation case: public functions.list/get/create/update/archive exist, but these calls moved verbatim from Studio, whose editor hydrates from the console get (which splices valid), and migrating them to the public operations is deferred; the workflow arm keeps the typed console calls its rail already used |
| src/repos/functions.ts | console.functions.list, .get, .upsert | moved verbatim from Studio and still on the console calls (the stored-definition read splices the server's valid; the guarded save sends ifUnmodifiedSince); public functions.list/get/update exist and migrating to them is deferred |
| src/repos/endpoints.ts | console.endpoints.get, .upsert, .invoke | endpoints have no public operation; the save keeps its own mutationFn because the 201 carries the rendered invokeUrls[] |
| src/draft/use-callable-draft.ts | console.functions.upsert, console.endpoints.upsert | the callable draft's guarded save (ifUnmodifiedSince) for the Function and Endpoint editors; endpoints have no public write operation, and the Function save is moved verbatim from Studio |
| src/repos/spec-export.ts | raw GET /:c/:id/spec | no public export operation (Settings export) |
| src/repos/apps.ts | console.apps.getActions, console.apps.get | the per-app Actions list and the app detail (version, source, actions) have no public operation; same GET /apps/:id payload the canvas adapter serves |
| src/repos/connections.ts | console.connections.listForApp, .create, .update, .test | no public per-app connection list, create, update or test; the list itself is the public connections.list |
| src/repos/connections.ts | console.connections.get, .delete | public connections.get/delete exist; these moved verbatim from Studio and still use the console calls, and migrating them is deferred |
| src/repos/saved-tests.ts | console.savedTests.list, .listTestRuns, .create, .update, .delete, .recordTestRun | saved tests and tester run history have no public operation |
| src/repos/reliability.ts | console.reliability.listMembers, console.reliability.list | no public reliability operation; the board's membership discovery and serial status batches |
| src/repos/api-calls.ts | console.apiCalls.list | no public operation; ServiceDetail's recent-calls log |
| @w6w/react createW6WUiAdapter (canvas) | console.apps.*, saved and step tests, subscriptions | the adapter's own call sites, used by the canvas from @w6w/ui/flow |
One cache
Inside <W6WEmbedProvider>, read data through the @w6w/embed/repos hooks (useFunctionsQuery,
useAppActionsQuery, useConnectionsForAppQuery, ...), not through @w6w/react's read hooks. The
repos hooks share the provider's identity-scoped TanStack QueryClient under fixed query keys, so a
save in one surface refreshes every other surface that shows the same record. @w6w/react's read
hooks keep their own useAsync state per hook call, which is a second cache: the two disagree after
a write and neither invalidates the other.
