From c5d4dd83c0576a50821468b118b305bc0c95c9e4 Mon Sep 17 00:00:00 2001 From: Amp Date: Thu, 10 Sep 2026 00:31:00 +0000 Subject: [PATCH 1/2] feat(frontend): add durable streams service to onboarding and namespace settings Amp-Thread-ID: https://ampcode.com/threads/T-01a087fe-49d0-705f-bc46-848784201ad1 --- .../images/brand/durable-streams-mark.svg | 18 + frontend/src/app/actors-grid.tsx | 8 +- frontend/src/app/compute-deploy.tsx | 12 + .../src/app/dialogs/add-component-frame.tsx | 26 +- .../dialogs/connect-durable-streams-frame.tsx | 35 ++ .../app/dialogs/connect-provider-sheet.tsx | 8 +- frontend/src/app/engine-namespace-landing.tsx | 10 +- frontend/src/app/getting-started.tsx | 331 ++++++++++++++++-- .../app/settings-pages/namespace-services.tsx | 137 ++++++++ .../app/settings-pages/namespace-settings.tsx | 2 + .../components/products/product-picker.tsx | 113 +++++- frontend/src/content/agent-prompts.test.ts | 49 +++ frontend/src/content/agent-prompts.ts | 155 +++++++- 13 files changed, 837 insertions(+), 67 deletions(-) create mode 100644 frontend/public/images/brand/durable-streams-mark.svg create mode 100644 frontend/src/app/dialogs/connect-durable-streams-frame.tsx create mode 100644 frontend/src/app/settings-pages/namespace-services.tsx diff --git a/frontend/public/images/brand/durable-streams-mark.svg b/frontend/public/images/brand/durable-streams-mark.svg new file mode 100644 index 0000000000..5655143b95 --- /dev/null +++ b/frontend/public/images/brand/durable-streams-mark.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/app/actors-grid.tsx b/frontend/src/app/actors-grid.tsx index a0ab9a8d3d..c74ccd466c 100644 --- a/frontend/src/app/actors-grid.tsx +++ b/frontend/src/app/actors-grid.tsx @@ -206,7 +206,7 @@ export function ActorsGrid({ namespaceLabel }: { namespaceLabel?: string }) {

- Actors + Components

@@ -223,11 +223,11 @@ export function ActorsGrid({ namespaceLabel }: { namespaceLabel?: string }) { ) : (

- No actors yet + No components yet

- Deploy code that registers an actor to - see it here. + Deploy code that registers an actor or + add a service to see it here.
diff --git a/frontend/src/app/compute-deploy.tsx b/frontend/src/app/compute-deploy.tsx index 3e14538f75..bd43911faa 100644 --- a/frontend/src/app/compute-deploy.tsx +++ b/frontend/src/app/compute-deploy.tsx @@ -16,6 +16,7 @@ import { type HostedTarget, hostedUrl } from "@/components/mcp/scope"; import { getAgentInstructionsPrompt, getComputeAddendum, + getDurableStreamsServiceUrl, type McpSetup, type OnboardingTarget, } from "@/content/agent-prompts"; @@ -75,6 +76,7 @@ export function useAgentInstructionsCode({ const secretToken = useRivetDsn({ kind: "secret", endpoint }); const mcp = useMcpSetup(); const namespace = useEngineCompatDataProvider().engineNamespace; + const durableStreamsServiceUrl = useDurableStreamsServiceUrl(); return getAgentInstructionsPrompt({ providerStr, @@ -89,9 +91,19 @@ export function useAgentInstructionsCode({ cliDeploy: provider === "rivet", target, mcp, + durableStreamsServiceUrl, }); } +// Where the managed Durable Streams service for this namespace is served on +// Rivet Cloud. Self-hosted flavors run the worker themselves, so there is no +// fixed URL to hand out. +export function useDurableStreamsServiceUrl(): string | undefined { + const namespace = useEngineCompatDataProvider().engineNamespace; + if (!features.compute) return undefined; + return getDurableStreamsServiceUrl(getRivetRunUrl(namespace)); +} + // The MCP setup the copy-prompt should instruct the agent to perform. The hosted // connection needs the user to approve an OAuth window, so the agent has to hand // that step back; the local stdio server it can wire up itself. diff --git a/frontend/src/app/dialogs/add-component-frame.tsx b/frontend/src/app/dialogs/add-component-frame.tsx index b3546d3b4b..cd03aa80eb 100644 --- a/frontend/src/app/dialogs/add-component-frame.tsx +++ b/frontend/src/app/dialogs/add-component-frame.tsx @@ -1,3 +1,5 @@ +import { useNavigate } from "@tanstack/react-router"; +import { CONNECT_DURABLE_STREAMS_MODAL } from "@/app/dialogs/connect-provider-sheet"; import { Frame } from "@/components"; import { getProductDocsUrl, @@ -9,6 +11,7 @@ export default function AddComponentFrameContent({ }: { onClose?: () => void; }) { + const navigate = useNavigate(); return ( <> @@ -21,11 +24,24 @@ export default function AddComponentFrameContent({ { - window.open( - getProductDocsUrl(target), - "_blank", - "noopener,noreferrer", - ); + // Products are added by writing code, so hand off to + // the docs. Services are connected in the dashboard, + // so open their setup sheet instead. + if (target === "durable-streams") { + void navigate({ + to: ".", + search: (s) => ({ + ...(s as Record), + modal: CONNECT_DURABLE_STREAMS_MODAL, + }), + }); + } else { + window.open( + getProductDocsUrl(target), + "_blank", + "noopener,noreferrer", + ); + } onClose?.(); }} /> diff --git a/frontend/src/app/dialogs/connect-durable-streams-frame.tsx b/frontend/src/app/dialogs/connect-durable-streams-frame.tsx new file mode 100644 index 0000000000..edfdd984ef --- /dev/null +++ b/frontend/src/app/dialogs/connect-durable-streams-frame.tsx @@ -0,0 +1,35 @@ +import { DurableStreamsConnect } from "@/app/getting-started"; +import { type DialogContentProps, Frame } from "@/components"; +import { getProduct, ProductMark } from "@/components/products/product-picker"; + +interface ConnectDurableStreamsFrameContentProps extends DialogContentProps {} + +// "Add Durable Streams" sheet. Durable Streams is a service rather than a +// provider, so instead of a runner config form this reuses the onboarding +// connect step: the managed service URL on cloud, the worker container +// command everywhere else. +export default function ConnectDurableStreamsFrameContent( + _props: ConnectDurableStreamsFrameContentProps, +) { + const product = getProduct("durable-streams"); + return ( + <> + + +
+ Add + + {product.label} +
+
+ {product.description} +
+ + + + + ); +} diff --git a/frontend/src/app/dialogs/connect-provider-sheet.tsx b/frontend/src/app/dialogs/connect-provider-sheet.tsx index d81d21cd98..099e442af6 100644 --- a/frontend/src/app/dialogs/connect-provider-sheet.tsx +++ b/frontend/src/app/dialogs/connect-provider-sheet.tsx @@ -14,8 +14,8 @@ type FrameLoader = () => Promise<{ default: ComponentType<{ onClose?: () => void }>; }>; -// Each "Add provider" modal maps to its connect frame, rendered inside the -// right-side drawer instead of a centered dialog. +// Each "Add provider" / "Add service" modal maps to its connect frame, +// rendered inside the right-side drawer instead of a centered dialog. const PROVIDER_FRAMES: Record = { "connect-rivet": () => import("@/app/dialogs/connect-rivet-frame"), "connect-vercel": () => import("@/app/dialogs/connect-vercel-frame"), @@ -28,8 +28,12 @@ const PROVIDER_FRAMES: Record = { "connect-aws": () => import("@/app/dialogs/connect-aws-frame"), "connect-gcp": () => import("@/app/dialogs/connect-gcp-frame"), "connect-hetzner": () => import("@/app/dialogs/connect-hetzner-frame"), + "connect-durable-streams": () => + import("@/app/dialogs/connect-durable-streams-frame"), }; +export const CONNECT_DURABLE_STREAMS_MODAL = "connect-durable-streams"; + export function isConnectProviderModal(modal: string | undefined): boolean { return typeof modal === "string" && modal in PROVIDER_FRAMES; } diff --git a/frontend/src/app/engine-namespace-landing.tsx b/frontend/src/app/engine-namespace-landing.tsx index 1054419f7a..1420b255b4 100644 --- a/frontend/src/app/engine-namespace-landing.tsx +++ b/frontend/src/app/engine-namespace-landing.tsx @@ -5,8 +5,8 @@ import { Button, H1, ScrollArea, SmallText, WithTooltip } from "@/components"; import { useEngineNamespaceDataProvider } from "@/components/actors"; import { NoProvidersAlert } from "@/components/actors/no-providers-alert"; import { VisibilitySensor } from "@/components/visibility-sensor"; -import { AddComponentButton, AddComponentCard } from "./add-component-card"; import { ActorBuildCard, ActorGridCardSkeleton } from "./actors-grid"; +import { AddComponentButton, AddComponentCard } from "./add-component-card"; // Engine (OSS / enterprise) namespace landing shown when no Actor name is // selected. This is the engine counterpart to the cloud `ActorsGrid`; keep the @@ -79,7 +79,7 @@ export function EngineNamespaceLanding() {

- Actors + Components

@@ -96,11 +96,11 @@ export function EngineNamespaceLanding() { ) : (

- No actors yet + No components yet

- Deploy code that registers an actor to - see it here. + Deploy code that registers an actor or + add a service to see it here.
diff --git a/frontend/src/app/getting-started.tsx b/frontend/src/app/getting-started.tsx index e2d3a38d74..ba06e4540d 100644 --- a/frontend/src/app/getting-started.tsx +++ b/frontend/src/app/getting-started.tsx @@ -26,6 +26,7 @@ import { CodeGroup, CodeGroupSyncProvider, CodePreview, + DiscreteCopyButton, FormField, Skeleton, } from "@/components"; @@ -33,14 +34,27 @@ import { useCloudNamespaceDataProvider, useEngineCompatDataProvider, } from "@/components/actors"; +import { AgentSelectStep } from "@/components/onboarding/agent-os/agent-select-step"; +import { buildAgentOsSetup } from "@/components/onboarding/agent-os/build-agent-os-setup"; +import { + DEFAULT_AGENT, + DEFAULT_PACKAGES, + DEFAULT_SANDBOX_PROVIDER, +} from "@/components/onboarding/agent-os/catalog"; +import { ProductPicker } from "@/components/products/product-picker"; import { defineStepper } from "@/components/ui/stepper"; import { + DURABLE_STREAMS_CLIENT_PACKAGE, + DURABLE_STREAMS_DEV_COMMAND, + DURABLE_STREAMS_DOCS_URL, + DURABLE_STREAMS_LOCAL_URL, + getDurableStreamsClientSnippet, + getDurableStreamUrl, getOnboardingTargetCopy, type OnboardingTarget, } from "@/content/agent-prompts"; import { deriveProviderFromMetadata } from "@/lib/data"; import { engineEnv } from "@/lib/env"; -import { ProductPicker } from "@/components/products/product-picker"; import { features } from "@/lib/features"; import { queryClient } from "@/queries/global"; import { cn } from "../components/lib/utils"; @@ -53,6 +67,14 @@ import { DropdownMenuTrigger, } from "../components/ui/dropdown-menu"; import { TEST_IDS } from "../utils/test-ids"; +import { + AgentPromptBanner, + CommandBox, + defaultRuntimeModeForProvider, + useAgentInstructionsCode, + useComputeInstructionsCode, + useDurableStreamsServiceUrl, +} from "./compute-deploy"; import { DeploymentCheck } from "./deployment-check"; import { useEndpoint } from "./dialogs/connect-manual-serverful-frame"; import { @@ -60,27 +82,13 @@ import { Configuration, ConfigurationAccordion, } from "./dialogs/connect-manual-serverless-frame"; -import { EnvVariables } from "./env-variables"; +import { EnvVariables, useRivetDsn } from "./env-variables"; import { StepperForm, StepVisibilityContext, useStepperFormSubmit, } from "./forms/stepper-form"; import { Content } from "./layout"; -import { AgentSelectStep } from "@/components/onboarding/agent-os/agent-select-step"; -import { buildAgentOsSetup } from "@/components/onboarding/agent-os/build-agent-os-setup"; -import { - DEFAULT_AGENT, - DEFAULT_PACKAGES, - DEFAULT_SANDBOX_PROVIDER, -} from "@/components/onboarding/agent-os/catalog"; -import { - AgentPromptBanner, - CommandBox, - defaultRuntimeModeForProvider, - useAgentInstructionsCode, - useComputeInstructionsCode, -} from "./compute-deploy"; function platformTitle(provider: unknown): string { return ( @@ -100,7 +108,13 @@ const stepper = defineStepper( // it must survive navigation past this step. schema: z.object({ template: z - .enum(["actor", "agent-os", "workflows", "dynamic-apps"]) + .enum([ + "actor", + "agent-os", + "workflows", + "dynamic-apps", + "durable-streams", + ]) .optional(), }), group: "local", @@ -140,6 +154,18 @@ const stepper = defineStepper( isVisible: (values: Record) => values.template === "agent-os", }, + // Services are managed by Rivet, so there is no runner or image to deploy. + // Durable Streams gets this step in place of the platform deploy below. + { + id: "services", + title: "Connect to Durable Streams", + next: "Done", + previous: "Back", + schema: z.object({}), + group: "deploy", + isVisible: (values: Record) => + values.template === "durable-streams", + }, { id: "deploy", title: "Deploy", @@ -149,6 +175,8 @@ const stepper = defineStepper( previous: "Back", assist: true, group: "deploy", + isVisible: (values: Record) => + values.template !== "durable-streams", schema: (values: Record) => { const provider = (values.provider as string) || "rivet"; if (provider === "rivet") { @@ -362,6 +390,11 @@ export function GettingStarted({ ), + services: () => ( + + + + ), deploy: () => ( ; const provider = (accumulated.provider ?? live.provider) as string | undefined; - if (provider && provider !== "rivet") { + const template = (accumulated.template ?? + live.template) as + | OnboardingTarget + | undefined; + // Services skip the platform deploy step, so + // the provider fields hold untouched defaults; + // writing them would register a bogus runner. + if ( + provider && + provider !== "rivet" && + template !== "durable-streams" + ) { await saveProviderConfig({ ...accumulated, provider, @@ -780,6 +824,9 @@ function SelectProductStep() { function RunLocallyStep() { const target = useOnboardingTarget(); const copy = getOnboardingTargetCopy(target); + if (target === "durable-streams") { + return ; + } return (
{features.compute ? ( @@ -788,31 +835,241 @@ function RunLocallyStep() { )} -
-
-

- Follow the quickstart guide + +

+ ); +} + +function QuickstartLink({ + description, + url, + label = "Quickstart guide", +}: { + description: string; + url: string; + label?: string; +}) { + return ( +
+
+

Follow the quickstart guide

+

{description}

+
+ +
+ ); +} + +// Durable Streams is a service, so "run locally" means starting the bundled dev +// server and pointing the client SDK at it. There is no project to scaffold, so +// the prompt is the generic one (no compute addendum) on every flavor. +function DurableStreamsRunLocally() { + const code = useAgentInstructionsCode({ target: "durable-streams" }); + const copy = getOnboardingTargetCopy("durable-streams"); + return ( +
+ + +
+ +
+

Start the dev server

+

+ Runs a local Rivet control plane and the Durable Streams + worker. Streams are served at{" "} + + {getDurableStreamUrl( + DURABLE_STREAMS_LOCAL_URL, + "", + )} + + .

-

- {copy.quickstartDescription} + +

+
+
+ +
+

Connect a client

+

+ Install the official client and append to your first + stream.

+
+ + +
-
+ ); +} + +function DurableStreamsClientSnippet({ serviceUrl }: { serviceUrl: string }) { + const code = getDurableStreamsClientSnippet(serviceUrl); + return ( + + {[ + code} + className="m-0" > - + , + ]} + + ); +} + +// Final step on the Durable Streams path. On Rivet Cloud the service is +// managed per namespace, so this hands out the service URL. Self-hosted +// flavors run the worker container against their own control plane instead. +// Also rendered by the "Add Durable Streams" sheet from namespace settings. +export function DurableStreamsConnect() { + const code = useAgentInstructionsCode({ target: "durable-streams" }); + const serviceUrl = useDurableStreamsServiceUrl(); + return ( +
+ + + {serviceUrl ? ( + + ) : ( + + )} +
+ ); +} + +function DurableStreamsManagedService({ serviceUrl }: { serviceUrl: string }) { + return ( + <> +
-
+
+ +
+

Point your client at it

+

+ Swap the local URL for the service URL. Streams live + under{" "} + + v1/stream/<path> + + . +

+ +
+
+ + ); +} + +function DurableStreamsSelfHosted() { + const endpoint = useRivetDsn({ kind: "secret" }); + const command = `docker run -p 8642:8642 \\ + -e RIVET_ENDPOINT="${endpoint}" \\ + -e HOST=0.0.0.0 \\ + rivetdev/services`; + return ( + <> +
+ +
+

Run the worker

+

+ The worker runs your streams and connects to this + namespace.{" "} + + RIVET_ENDPOINT + {" "} + contains an admin credential, so keep it out of source + control and browser code. +

+ +
+
+
+ +
+

Point your client at it

+

+ Streams are served at{" "} + + http://<host>:8642/durable-streams/v1/stream/<path> + + . See the{" "} + + integration guide + {" "} + for the control plane flags Durable Streams needs. +

+ :8642/durable-streams/"} + /> +
+
+ ); } diff --git a/frontend/src/app/settings-pages/namespace-services.tsx b/frontend/src/app/settings-pages/namespace-services.tsx new file mode 100644 index 0000000000..dcf4325f76 --- /dev/null +++ b/frontend/src/app/settings-pages/namespace-services.tsx @@ -0,0 +1,137 @@ +import { faPlus, Icon } from "@rivet-gg/icons"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { CONNECT_DURABLE_STREAMS_MODAL } from "@/app/dialogs/connect-provider-sheet"; +import { + Button, + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Ping, +} from "@/components"; +import { useEngineCompatDataProvider } from "@/components/actors"; +import { getProduct, ProductMark } from "@/components/products/product-picker"; +import { SettingsCard } from "./settings-card"; + +// Services are Rivet-run workers the user connects to a namespace rather than +// code they deploy. Each one registers well-known actor names, so a namespace +// "has" the service once any of those names shows up in its builds. +const SERVICES = [ + { + product: getProduct("durable-streams"), + modal: CONNECT_DURABLE_STREAMS_MODAL, + actorNames: ["durableStream"], + }, +]; + +export function Services() { + const navigate = useNavigate(); + const dataProvider = useEngineCompatDataProvider(); + const { data: builds = [] } = useInfiniteQuery( + dataProvider.buildsQueryOptions(), + ); + + const openSetup = (modal: string) => + navigate({ + to: ".", + search: (old) => ({ + ...(old as Record), + modal, + }), + }); + + return ( + + + + + + {SERVICES.map((service) => ( + + } + onSelect={() => openSetup(service.modal)} + > + {service.product.label} + + ))} + + + } + > + {SERVICES.map((service, idx) => { + const connected = builds.some((build) => + service.actorNames.includes(build.id), + ); + return ( +
+
+ +
+
+ {service.product.label} +
+
+ {service.product.description} +
+
+
+
+ {connected ? ( + + + Connected + + ) : ( + + Not connected + + )} + +
+
+ ); + })} +
+ ); +} diff --git a/frontend/src/app/settings-pages/namespace-settings.tsx b/frontend/src/app/settings-pages/namespace-settings.tsx index ae1a3c31e0..c1514e3e46 100644 --- a/frontend/src/app/settings-pages/namespace-settings.tsx +++ b/frontend/src/app/settings-pages/namespace-settings.tsx @@ -25,6 +25,7 @@ import { SecretToken, } from "@/routes/_context/orgs.$organization/projects.$project/ns.$namespace/tokens"; import { McpConnection } from "./mcp-connection"; +import { Services } from "./namespace-services"; import { SettingsCard } from "./settings-card"; export function NamespaceSettingsContent() { @@ -32,6 +33,7 @@ export function NamespaceSettingsContent() {
+
); diff --git a/frontend/src/components/products/product-picker.tsx b/frontend/src/components/products/product-picker.tsx index e360ade462..a241a5c515 100644 --- a/frontend/src/components/products/product-picker.tsx +++ b/frontend/src/components/products/product-picker.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { cn } from "@/components/lib/utils"; import { Badge } from "@/components/ui/badge"; import { getOnboardingTargetCopy, @@ -7,8 +8,14 @@ import { import { features } from "@/lib/features"; import { publicUrl } from "@/lib/utils"; +// Products are SDKs the user builds on and deploys themselves. Services are +// managed by Rivet: the user only points a client at them, so they onboard +// through a different path and are listed under their own heading. +export type ProductSection = "products" | "services"; + type Product = { target: OnboardingTarget; + section: ProductSection; label: string; description: string; markFileName: string; @@ -19,6 +26,7 @@ type Product = { const PRODUCTS: Product[] = [ { target: "actor", + section: "products", label: "Actors", description: "The primitive for realtime, stateful workloads", markFileName: "actors-mark.svg", @@ -26,6 +34,7 @@ const PRODUCTS: Product[] = [ }, { target: "agent-os", + section: "products", label: "agentOS", description: "Hand every agent a computer of its own", markFileName: "agentos-mark.svg", @@ -33,6 +42,7 @@ const PRODUCTS: Product[] = [ }, { target: "workflows", + section: "products", label: "Workflows", description: "Write multi-step operations that survive restarts", markFileName: "workflows-mark.svg", @@ -40,32 +50,92 @@ const PRODUCTS: Product[] = [ }, { target: "dynamic-apps", + section: "products", label: "Dynamic Apps", description: "Deploy AI-generated apps for your users", markFileName: "dynamic-apps-mark.svg", badge: "Preview", isAvailable: () => true, }, + { + target: "durable-streams", + section: "services", + label: "Durable Streams", + description: "Real-time streams with durable, replayable history", + markFileName: "durable-streams-mark.svg", + isAvailable: () => true, + }, +]; + +const SECTIONS: { id: ProductSection; label: string }[] = [ + { id: "products", label: "Products" }, + { id: "services", label: "Services" }, ]; export function getAvailableProducts() { return PRODUCTS.filter((p) => p.isAvailable()); } +export function getProductSections() { + const available = getAvailableProducts(); + return SECTIONS.map((section) => ({ + ...section, + products: available.filter((p) => p.section === section.id), + })).filter((section) => section.products.length > 0); +} + +export function getProduct(target: OnboardingTarget) { + const product = PRODUCTS.find((p) => p.target === target); + if (!product) { + throw new Error(`Unknown product: ${target}`); + } + return product; +} + export function getProductDocsUrl(target: OnboardingTarget) { return getOnboardingTargetCopy(target).quickstartUrl; } -export function ProductMark({ fileName }: { fileName: string }) { - return ( +// Product marks ship as tiles (logo inside a colored rounded square with an +// inner ring). Service marks are the bare third-party logo, so give them a +// neutral tile here to match the products' visual weight. +export function ProductMark({ + fileName, + section, + size = "md", +}: { + fileName: string; + section: ProductSection; + /** `md` is the picker card size; `sm` fits inline in menus and text. */ + size?: "sm" | "md"; +}) { + const isService = section === "services"; + const img = ( ); + if (!isService) { + return img; + } + return ( + + {img} + + ); } export function ProductCard({ @@ -122,17 +192,34 @@ export function ProductPicker({
- {getAvailableProducts().map((product) => ( - } - label={product.label} - description={product.description} - badge={product.badge} - onSelect={() => onSelect(product.target)} - /> + {getProductSections().map((section) => ( +
+ + {section.label} + +
+ {section.products.map((product) => ( + + } + label={product.label} + description={product.description} + badge={product.badge} + onSelect={() => onSelect(product.target)} + /> + ))} +
+
))}

diff --git a/frontend/src/content/agent-prompts.test.ts b/frontend/src/content/agent-prompts.test.ts index 70f4ddc9d4..a3e432df75 100644 --- a/frontend/src/content/agent-prompts.test.ts +++ b/frontend/src/content/agent-prompts.test.ts @@ -83,6 +83,55 @@ describe("onboarding product prompts", () => { expect(prompt).not.toContain("Drive actors via the inspector HTTP API"); }); + it("treats Durable Streams as a client-only service", () => { + const prompt = getAgentInstructionsPrompt({ + ...agentPromptOptions, + target: "durable-streams", + }); + + expect(prompt).toContain("# Durable Streams Setup"); + expect(prompt).toContain("npx @rivet-dev/services dev"); + expect(prompt).toContain("npm install @durable-streams/client"); + expect(prompt).toContain( + "http://127.0.0.1:8642/durable-streams/v1/stream/demo", + ); + expect(prompt).toContain( + "https://rivet.dev/actors/integrations/durable-streams", + ); + expect(prompt).not.toContain("npm install rivetkit"); + expect(prompt).not.toContain("registry.listen"); + expect(prompt).not.toContain("RIVET_PUBLIC_ENDPOINT"); + }); + + it("hands out the managed service URL for Durable Streams on Rivet Cloud", () => { + const prompt = getAgentInstructionsPrompt({ + ...agentPromptOptions, + target: "durable-streams", + durableStreamsServiceUrl: + "https://onboarding-test.staging.rivet.run/durable-streams/", + }); + + expect(prompt).toContain( + "https://onboarding-test.staging.rivet.run/durable-streams/v1/stream/", + ); + expect(prompt).toContain( + "managed service in the `onboarding-test` namespace", + ); + expect(prompt).not.toContain("docker run"); + expect(prompt).not.toContain("secret_test"); + }); + + it("runs the Durable Streams worker against the control plane when self-hosting", () => { + const prompt = getAgentInstructionsPrompt({ + ...agentPromptOptions, + target: "durable-streams", + }); + + expect(prompt).toContain("rivetdev/services"); + expect(prompt).toContain('RIVET_ENDPOINT="secret_test"'); + expect(prompt).not.toContain("rivet.run/durable-streams/"); + }); + it("replaces Actor verification with an app URL for Dynamic Apps on Compute", () => { const prompt = getComputeAddendum({ ...computePromptOptions, diff --git a/frontend/src/content/agent-prompts.ts b/frontend/src/content/agent-prompts.ts index 3be81ebe07..dca44ee16a 100644 --- a/frontend/src/content/agent-prompts.ts +++ b/frontend/src/content/agent-prompts.ts @@ -2,7 +2,47 @@ export type OnboardingTarget = | "actor" | "agent-os" | "workflows" - | "dynamic-apps"; + | "dynamic-apps" + | "durable-streams"; + +// Durable Streams is a managed service rather than user code, so the local +// dev server and the client SDK are fixed and the prompts can name them. +export const DURABLE_STREAMS_DOCS_URL = + "https://rivet.dev/actors/integrations/durable-streams"; +export const DURABLE_STREAMS_DEV_COMMAND = "npx @rivet-dev/services dev"; +export const DURABLE_STREAMS_LOCAL_URL = + "http://127.0.0.1:8642/durable-streams/"; +export const DURABLE_STREAMS_CLIENT_PACKAGE = "@durable-streams/client"; + +// Path under a Rivet Run origin where the managed Durable Streams service is +// served, e.g. `https://.rivet.run/durable-streams/`. +export function getDurableStreamsServiceUrl(rivetRunUrl: string) { + return `${rivetRunUrl.replace(/\/?$/, "/")}durable-streams/`; +} + +// The Durable Streams protocol mounts streams at `v1/stream/` under the +// service origin. +export function getDurableStreamUrl(serviceUrl: string, streamPath: string) { + return `${serviceUrl.replace(/\/?$/, "/")}v1/stream/${streamPath}`; +} + +export function getDurableStreamsClientSnippet(serviceUrl: string) { + return `import { DurableStream } from "${DURABLE_STREAMS_CLIENT_PACKAGE}"; + +const stream = await DurableStream.create({ + url: "${getDurableStreamUrl(serviceUrl, "demo")}", + contentType: "application/json", +}); + +await stream.append(JSON.stringify({ message: "hello" })); + +const res = await stream.stream<{ message: string }>(); +res.subscribeJson(async (batch) => { + for (const item of batch.items) { + console.log(item.message); + } +});`; +} const onboardingTargetCopy: Record< OnboardingTarget, @@ -35,6 +75,12 @@ const onboardingTargetCopy: Record< "Build a Dynamic Apps host and deploy a sample app by hand.", quickstartUrl: "https://rivet.dev/dynamic-apps/docs/quickstart/", }, + "durable-streams": { + promptObject: "a Durable Streams client", + quickstartDescription: + "Run Durable Streams locally and connect a client by hand.", + quickstartUrl: DURABLE_STREAMS_DOCS_URL, + }, }; export function getOnboardingTargetCopy(target: OnboardingTarget) { @@ -172,6 +218,100 @@ ${mcpSection}## Step 4: Verify the workflow end-to-end Report the workflow host URL, command used, action or queue invoked, observed result, and any remaining setup the user must complete.`; } +// Durable Streams ships as a service, so there is no user code to deploy. The +// prompt walks the agent through the local dev server, the client SDK, and then +// either the managed service URL (Rivet Cloud) or running the worker container +// against the user's own control plane (self-hosted). +function getDurableStreamsPrompt({ + secretToken, + namespace, + durableStreamsServiceUrl, + mcpSection, +}: { + secretToken: string; + namespace?: string; + durableStreamsServiceUrl?: string; + mcpSection: string; +}) { + const localStreamUrl = getDurableStreamUrl( + DURABLE_STREAMS_LOCAL_URL, + "", + ); + const deploySteps = durableStreamsServiceUrl + ? `Durable Streams runs as a managed service in the \`${namespace ?? "selected"}\` namespace on Rivet Cloud. There is nothing to build or deploy. + +1. Point the client at the managed service instead of the local server. Streams live at \`${getDurableStreamUrl(durableStreamsServiceUrl, "")}\`. +2. Read the service URL from an environment variable (for example \`DURABLE_STREAMS_URL\`) so local development keeps using \`${DURABLE_STREAMS_LOCAL_URL}\` and production uses \`${durableStreamsServiceUrl}\`. +3. Verify against the managed service: append one record and read it back with \`curl '${getDurableStreamUrl(durableStreamsServiceUrl, "demo")}?offset=-1'\`.` + : `Durable Streams runs as a single worker container that connects to the user's Rivet control plane. + +1. Run the worker next to the application and point it at the control plane. \`RIVET_ENDPOINT\` contains a secret admin credential, so write it to the platform's secret store or a local \`.env\` that is listed in \`.gitignore\`. Never commit it and never expose it to browser code: + \`\`\`bash + docker run -p 8642:8642 \\ + -e RIVET_ENDPOINT="${secretToken}" \\ + -e HOST=0.0.0.0 \\ + rivetdev/services + \`\`\` +2. Point the client at the worker. Streams live at \`http://:8642/durable-streams/v1/stream/\`. Read the URL from an environment variable (for example \`DURABLE_STREAMS_URL\`) so local development keeps using \`${DURABLE_STREAMS_LOCAL_URL}\`. +3. Verify against the deployed worker: append one record and read it back with \`curl 'http://:8642/durable-streams/v1/stream/demo?offset=-1'\`.`; + + return `# Durable Streams Setup + +Read the Durable Streams integration guide before changing the project: ${DURABLE_STREAMS_DOCS_URL} + +Durable Streams is an open standard for real-time streams with durable, replayable history. Rivet runs it as a service backed by Rivet Actors, so the project only needs a client. Do not scaffold Rivet Actors, a RivetKit registry, or a Dockerfile for this. + +## Step 1: Understand the project + +Determine whether the user wants a new project or wants to add Durable Streams to the existing application. Inspect the package manager, runtime, and any existing streaming or realtime code first. Good fits are agent session transcripts, CRDT sync, and change feeds that clients resume from an offset. + +## Step 2: Run Durable Streams locally + +Start the local Rivet control plane and the Durable Streams worker in a separate terminal: + +\`\`\`bash +${DURABLE_STREAMS_DEV_COMMAND} +\`\`\` + +Streams are then available at \`${localStreamUrl}\`. If the project already runs Rivet Actors with the RivetKit TypeScript SDK (2.3.12 or newer), Durable Streams is already served by that process at \`http://127.0.0.1:6420/durable-streams/v1/stream/\` and this command is not needed. + +## Step 3: Connect a client + +- Install the official client with the project's package manager: \`npm install ${DURABLE_STREAMS_CLIENT_PACKAGE}\`. +- Create a stream and append to it. Keep the base URL in one place so it can point at production later: + +\`\`\`ts +${getDurableStreamsClientSnippet(DURABLE_STREAMS_LOCAL_URL)} +\`\`\` + +- Readers can catch up from offset \`-1\` (the beginning), resume from a saved offset, or tail live. Persist the offset from the \`Stream-Next-Offset\` header when the application needs to resume. +- For structured data, JSON mode, Yjs, TanStack AI, or the Vercel AI SDK, follow https://durablestreams.com instead of inventing a wire format. + +## Step 4: Verify locally + +Create a stream with one record, append a second, and read both back over HTTP, then run the application's own read path: + +\`\`\`bash +curl -i -X PUT -H 'content-type: application/json' --data '[{"message":"hello"}]' ${getDurableStreamUrl(DURABLE_STREAMS_LOCAL_URL, "demo")} +curl -i -X POST -H 'content-type: application/json' --data '{"message":"world"}' ${getDurableStreamUrl(DURABLE_STREAMS_LOCAL_URL, "demo")} +curl '${getDurableStreamUrl(DURABLE_STREAMS_LOCAL_URL, "demo")}?offset=-1' +\`\`\` + +Report the commands run and the observed result. + +## Step 5: Deploy + +${deploySteps} + +${mcpSection}## If you get stuck + +Check ${DURABLE_STREAMS_DOCS_URL} and https://durablestreams.com. If that doesn't help, point the user at: +- Discord: https://rivet.dev/discord +- GitHub issues: https://github.com/rivet-dev/rivet-durable-streams + +Include in the report: symptoms, what was tried, the client version, and the stream URL in use.`; +} + // The hosted connection authorizes against the user's Rivet account through a // browser window, so the agent has to hand that step back. The local server is // plain stdio and the agent can run it itself. @@ -390,6 +530,7 @@ export function getAgentInstructionsPrompt({ cliDeploy, target = "actor", mcp, + durableStreamsServiceUrl, }: { providerStr: string; publishableToken: string; @@ -403,7 +544,19 @@ export function getAgentInstructionsPrompt({ cliDeploy?: boolean; target?: OnboardingTarget; mcp?: McpSetup; + // Managed Durable Streams service URL for this namespace on Rivet Cloud. + // Omitted when self-hosting, where the agent runs the worker itself. + durableStreamsServiceUrl?: string; }) { + if (target === "durable-streams") { + return getDurableStreamsPrompt({ + secretToken, + namespace, + durableStreamsServiceUrl, + mcpSection: mcp ? getMcpSection(mcp) : "", + }); + } + const poolLine = runnerName !== "default" ? `\n RIVET_POOL=${runnerName}` : ""; // Compute appends its own addendum with the same section; emitting it twice From c3365d72f1c6c708af24d427200f6ef79df85c31 Mon Sep 17 00:00:00 2001 From: Amp Date: Thu, 10 Sep 2026 00:40:55 +0000 Subject: [PATCH 2/2] feat(frontend): move services to its own settings tab and link to docs Amp-Thread-ID: https://ampcode.com/threads/T-01a087fe-49d0-705f-bc46-848784201ad1 --- .../src/app/dialogs/add-component-frame.tsx | 26 +-- .../dialogs/connect-durable-streams-frame.tsx | 35 --- .../app/dialogs/connect-provider-sheet.tsx | 8 +- frontend/src/app/getting-started.tsx | 2 +- frontend/src/app/settings-drawer.tsx | 49 ++++ .../app/settings-pages/namespace-services.tsx | 212 +++++++++--------- .../app/settings-pages/namespace-settings.tsx | 2 - 7 files changed, 168 insertions(+), 166 deletions(-) delete mode 100644 frontend/src/app/dialogs/connect-durable-streams-frame.tsx diff --git a/frontend/src/app/dialogs/add-component-frame.tsx b/frontend/src/app/dialogs/add-component-frame.tsx index cd03aa80eb..b3546d3b4b 100644 --- a/frontend/src/app/dialogs/add-component-frame.tsx +++ b/frontend/src/app/dialogs/add-component-frame.tsx @@ -1,5 +1,3 @@ -import { useNavigate } from "@tanstack/react-router"; -import { CONNECT_DURABLE_STREAMS_MODAL } from "@/app/dialogs/connect-provider-sheet"; import { Frame } from "@/components"; import { getProductDocsUrl, @@ -11,7 +9,6 @@ export default function AddComponentFrameContent({ }: { onClose?: () => void; }) { - const navigate = useNavigate(); return ( <> @@ -24,24 +21,11 @@ export default function AddComponentFrameContent({ { - // Products are added by writing code, so hand off to - // the docs. Services are connected in the dashboard, - // so open their setup sheet instead. - if (target === "durable-streams") { - void navigate({ - to: ".", - search: (s) => ({ - ...(s as Record), - modal: CONNECT_DURABLE_STREAMS_MODAL, - }), - }); - } else { - window.open( - getProductDocsUrl(target), - "_blank", - "noopener,noreferrer", - ); - } + window.open( + getProductDocsUrl(target), + "_blank", + "noopener,noreferrer", + ); onClose?.(); }} /> diff --git a/frontend/src/app/dialogs/connect-durable-streams-frame.tsx b/frontend/src/app/dialogs/connect-durable-streams-frame.tsx deleted file mode 100644 index edfdd984ef..0000000000 --- a/frontend/src/app/dialogs/connect-durable-streams-frame.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { DurableStreamsConnect } from "@/app/getting-started"; -import { type DialogContentProps, Frame } from "@/components"; -import { getProduct, ProductMark } from "@/components/products/product-picker"; - -interface ConnectDurableStreamsFrameContentProps extends DialogContentProps {} - -// "Add Durable Streams" sheet. Durable Streams is a service rather than a -// provider, so instead of a runner config form this reuses the onboarding -// connect step: the managed service URL on cloud, the worker container -// command everywhere else. -export default function ConnectDurableStreamsFrameContent( - _props: ConnectDurableStreamsFrameContentProps, -) { - const product = getProduct("durable-streams"); - return ( - <> - - -

- Add - - {product.label} -
- - {product.description} - - - - - - ); -} diff --git a/frontend/src/app/dialogs/connect-provider-sheet.tsx b/frontend/src/app/dialogs/connect-provider-sheet.tsx index 099e442af6..d81d21cd98 100644 --- a/frontend/src/app/dialogs/connect-provider-sheet.tsx +++ b/frontend/src/app/dialogs/connect-provider-sheet.tsx @@ -14,8 +14,8 @@ type FrameLoader = () => Promise<{ default: ComponentType<{ onClose?: () => void }>; }>; -// Each "Add provider" / "Add service" modal maps to its connect frame, -// rendered inside the right-side drawer instead of a centered dialog. +// Each "Add provider" modal maps to its connect frame, rendered inside the +// right-side drawer instead of a centered dialog. const PROVIDER_FRAMES: Record = { "connect-rivet": () => import("@/app/dialogs/connect-rivet-frame"), "connect-vercel": () => import("@/app/dialogs/connect-vercel-frame"), @@ -28,12 +28,8 @@ const PROVIDER_FRAMES: Record = { "connect-aws": () => import("@/app/dialogs/connect-aws-frame"), "connect-gcp": () => import("@/app/dialogs/connect-gcp-frame"), "connect-hetzner": () => import("@/app/dialogs/connect-hetzner-frame"), - "connect-durable-streams": () => - import("@/app/dialogs/connect-durable-streams-frame"), }; -export const CONNECT_DURABLE_STREAMS_MODAL = "connect-durable-streams"; - export function isConnectProviderModal(modal: string | undefined): boolean { return typeof modal === "string" && modal in PROVIDER_FRAMES; } diff --git a/frontend/src/app/getting-started.tsx b/frontend/src/app/getting-started.tsx index ba06e4540d..016fed5550 100644 --- a/frontend/src/app/getting-started.tsx +++ b/frontend/src/app/getting-started.tsx @@ -958,7 +958,7 @@ function DurableStreamsClientSnippet({ serviceUrl }: { serviceUrl: string }) { // managed per namespace, so this hands out the service URL. Self-hosted // flavors run the worker container against their own control plane instead. // Also rendered by the "Add Durable Streams" sheet from namespace settings. -export function DurableStreamsConnect() { +function DurableStreamsConnect() { const code = useAgentInstructionsCode({ target: "durable-streams" }); const serviceUrl = useDurableStreamsServiceUrl(); return ( diff --git a/frontend/src/app/settings-drawer.tsx b/frontend/src/app/settings-drawer.tsx index 380dee2d8a..4ed5b34512 100644 --- a/frontend/src/app/settings-drawer.tsx +++ b/frontend/src/app/settings-drawer.tsx @@ -5,6 +5,7 @@ import { faClose, faCreditCard, faGear, + faPuzzlePiece, faRivet, faSliders, faSparkles, @@ -30,6 +31,7 @@ import { BillingUsageGauge } from "./billing/billing-usage-gauge"; import { PoolSwitcher, poolHeaderText, resolvePoolName } from "./pool-switcher"; import { BillingPanel } from "./settings-pages/billing-panel"; import { NamespaceComputeContent } from "./settings-pages/namespace-compute"; +import { NamespaceServicesContent } from "./settings-pages/namespace-services"; import { NamespaceAdvancedContent, NamespaceSettingsContent, @@ -42,6 +44,7 @@ import { WhatsNewPanel } from "./settings-pages/whats-new-panel"; export type SettingsTab = | "profile" | "settings" + | "services" | "compute" | "advanced" | "billing" @@ -64,6 +67,7 @@ const NAV_SECTIONS: Array<{ label: "Namespace", items: [ { key: "settings", label: "Settings", icon: faGear }, + { key: "services", label: "Services", icon: faPuzzlePiece }, { key: "compute", label: "Compute", icon: faRivet }, { key: "advanced", label: "Advanced", icon: faSliders }, ], @@ -96,6 +100,11 @@ const TAB_META: Record = { ? "Connect your RivetKit application to Rivet Cloud. Use your cloud of choice to run Rivet Actors." : "Connect providers and runners to this namespace. Use your cloud of choice to run Rivet Actors.", }, + services: { + title: "Services", + description: + "Managed services you can connect to this namespace, such as Durable Streams.", + }, compute: { title: "Compute", description: "Manage the Rivet Compute deployment for this namespace.", @@ -366,6 +375,8 @@ function TabContent({ tab }: { tab: SettingsTab }) { return ; case "settings": return ; + case "services": + return ; case "compute": return ; case "advanced": @@ -660,12 +671,50 @@ function CloudAdvancedTabBody() { return ; } +function ServicesTabBody() { + if (!features.platform) { + return ; + } + return ; +} + +function EngineNamespaceServices() { + return useEngineNamespaceReady() ? ( + + ) : ( + + ); +} + +function CloudServicesTabBody() { + const namespaceMatch = useMatch({ + from: "/_context/orgs/$organization/projects/$project/ns/$namespace", + shouldThrow: false, + }); + + if (!namespaceMatch) { + return ( + + ); + } + if (!namespaceMatch.loaderData) { + return ; + } + return ; +} + export function settingsParamToTab( param: string | undefined, ): SettingsTab | null { switch (param) { case "profile": case "settings": + case "services": case "advanced": case "billing": case "organization": diff --git a/frontend/src/app/settings-pages/namespace-services.tsx b/frontend/src/app/settings-pages/namespace-services.tsx index dcf4325f76..1fcfd373ff 100644 --- a/frontend/src/app/settings-pages/namespace-services.tsx +++ b/frontend/src/app/settings-pages/namespace-services.tsx @@ -1,7 +1,5 @@ -import { faPlus, Icon } from "@rivet-gg/icons"; +import { faExternalLink, faPlus, Icon } from "@rivet-gg/icons"; import { useInfiniteQuery } from "@tanstack/react-query"; -import { useNavigate } from "@tanstack/react-router"; -import { CONNECT_DURABLE_STREAMS_MODAL } from "@/app/dialogs/connect-provider-sheet"; import { Button, cn, @@ -12,126 +10,138 @@ import { Ping, } from "@/components"; import { useEngineCompatDataProvider } from "@/components/actors"; -import { getProduct, ProductMark } from "@/components/products/product-picker"; +import { + getProduct, + getProductDocsUrl, + ProductMark, +} from "@/components/products/product-picker"; import { SettingsCard } from "./settings-card"; // Services are Rivet-run workers the user connects to a namespace rather than // code they deploy. Each one registers well-known actor names, so a namespace -// "has" the service once any of those names shows up in its builds. +// "has" the service once any of those names shows up in its builds. Setup +// instructions live in the docs so they are not duplicated here. const SERVICES = [ { product: getProduct("durable-streams"), - modal: CONNECT_DURABLE_STREAMS_MODAL, actorNames: ["durableStream"], }, ]; -export function Services() { - const navigate = useNavigate(); +function openDocs(target: (typeof SERVICES)[number]["product"]["target"]) { + window.open(getProductDocsUrl(target), "_blank", "noopener,noreferrer"); +} + +export function NamespaceServicesContent() { const dataProvider = useEngineCompatDataProvider(); const { data: builds = [] } = useInfiniteQuery( dataProvider.buildsQueryOptions(), ); - const openSetup = (modal: string) => - navigate({ - to: ".", - search: (old) => ({ - ...(old as Record), - modal, - }), - }); - return ( - - - - - - {SERVICES.map((service) => ( - - } - onSelect={() => openSetup(service.modal)} - > - {service.product.label} - - ))} - - - } - > - {SERVICES.map((service, idx) => { - const connected = builds.some((build) => - service.actorNames.includes(build.id), - ); - return ( -
-
- -
-
- {service.product.label} -
-
- {service.product.description} -
-
-
-
- {connected ? ( - - - Connected - - ) : ( - - Not connected - - )} +
+ + + + + {SERVICES.map((service) => ( + + } + onSelect={() => + openDocs(service.product.target) + } + > + {service.product.label} + + ))} + + + } + > + {SERVICES.map((service, idx) => { + const connected = builds.some((build) => + service.actorNames.includes(build.id), + ); + return ( +
+
+ +
+
+ {service.product.label} +
+
+ {service.product.description} +
+
+
+
+ {connected ? ( + + + Connected + + ) : ( + + Not connected + + )} + +
-
- ); - })} - + ); + })} + +
); } diff --git a/frontend/src/app/settings-pages/namespace-settings.tsx b/frontend/src/app/settings-pages/namespace-settings.tsx index c1514e3e46..ae1a3c31e0 100644 --- a/frontend/src/app/settings-pages/namespace-settings.tsx +++ b/frontend/src/app/settings-pages/namespace-settings.tsx @@ -25,7 +25,6 @@ import { SecretToken, } from "@/routes/_context/orgs.$organization/projects.$project/ns.$namespace/tokens"; import { McpConnection } from "./mcp-connection"; -import { Services } from "./namespace-services"; import { SettingsCard } from "./settings-card"; export function NamespaceSettingsContent() { @@ -33,7 +32,6 @@ export function NamespaceSettingsContent() {
-
);