Skip to content

Commit f0642e3

Browse files
committed
feat: vercel connection frame
1 parent 0ab3357 commit f0642e3

File tree

13 files changed

+838
-48
lines changed

13 files changed

+838
-48
lines changed

frontend/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,12 @@
6060
"@radix-ui/react-visually-hidden": "^1.2.3",
6161
"@rivet-gg/cloud": "https://pkg.pr.new/rivet-dev/cloud/@rivet-gg/cloud@7090780",
6262
"@rivet-gg/icons": "workspace:*",
63-
"rivetkit": "*",
6463
"@rivetkit/engine-api-full": "workspace:*",
6564
"@sentry/react": "^8.55.0",
6665
"@sentry/vite-plugin": "^2.23.1",
6766
"@shikijs/langs": "^3.12.2",
6867
"@shikijs/transformers": "^3.12.2",
68+
"@stepperize/react": "^5.1.8",
6969
"@tailwindcss/container-queries": "^0.1.1",
7070
"@tailwindcss/typography": "^0.5.16",
7171
"@tanstack/query-core": "^5.87.1",
@@ -118,6 +118,7 @@
118118
"react-inspector": "^6.0.2",
119119
"react-resizable-panels": "^2.1.9",
120120
"recharts": "^2.15.4",
121+
"rivetkit": "*",
121122
"shiki": "^3.12.2",
122123
"sonner": "^1.7.4",
123124
"tailwind-merge": "^2.6.0",
Lines changed: 107 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,45 @@
11
import { faQuestionCircle, faVercel, Icon } from "@rivet-gg/icons";
2-
import { useMutation } from "@tanstack/react-query";
32
import * as ConnectVercelForm from "@/app/forms/connect-vercel-form";
43
import { HelpDropdown } from "@/app/help-dropdown";
5-
import { Button, Flex, Frame } from "@/components";
6-
import { useEngineCompatDataProvider } from "@/components/actors";
4+
import {
5+
Button,
6+
type DialogContentProps,
7+
Frame,
8+
Step,
9+
Steps,
10+
} from "@/components";
11+
import { defineStepper } from "@/components/ui/stepper";
712

8-
export default function CreateProjectFrameContent() {
9-
const provider = useEngineCompatDataProvider();
13+
const { Stepper } = defineStepper(
14+
{
15+
id: "step-1",
16+
title: "Select Vercel Plan",
17+
},
18+
{
19+
id: "step-2",
20+
title: "Edit vercel.json",
21+
},
22+
{
23+
id: "step-3",
24+
title: "Deploy to Vercel",
25+
},
26+
{
27+
id: "step-4",
28+
title: "Add Vercel Endpoint",
29+
},
30+
);
1031

11-
const { mutateAsync } = useMutation(
12-
provider.createRunnerConfigMutationOptions(),
13-
);
32+
interface CreateProjectFrameContentProps extends DialogContentProps {}
1433

34+
export default function CreateProjectFrameContent({
35+
onClose,
36+
}: CreateProjectFrameContentProps) {
1537
return (
1638
<ConnectVercelForm.Form
17-
onSubmit={async (values) => {
18-
await mutateAsync({
19-
name: values.name,
20-
config: {
21-
serverless: {
22-
url: values.endpoint,
23-
},
24-
},
25-
});
26-
}}
27-
defaultValues={{ name: "" }}
39+
onSubmit={async () => {}}
40+
mode="onChange"
41+
revalidateMode="onChange"
42+
defaultValues={{ plan: "hobby", endpoint: "" }}
2843
>
2944
<Frame.Header>
3045
<Frame.Title className="justify-between flex items-center">
@@ -40,16 +55,80 @@ export default function CreateProjectFrameContent() {
4055
</Frame.Title>
4156
</Frame.Header>
4257
<Frame.Content>
43-
<Flex gap="4" direction="col">
44-
<ConnectVercelForm.Plan />
45-
<ConnectVercelForm.Endpoint />
46-
</Flex>
58+
<FormStepper onClose={onClose} />
4759
</Frame.Content>
48-
<Frame.Footer>
49-
<ConnectVercelForm.Submit type="submit">
50-
Add
51-
</ConnectVercelForm.Submit>
52-
</Frame.Footer>
5360
</ConnectVercelForm.Form>
5461
);
5562
}
63+
64+
function FormStepper({ onClose }: { onClose?: () => void }) {
65+
return (
66+
<Stepper.Provider variant="vertical">
67+
{({ methods }) => (
68+
<>
69+
<Stepper.Navigation>
70+
{methods.all.map((step) => (
71+
<Stepper.Step
72+
className="min-w-0"
73+
of={step.id}
74+
onClick={() => methods.goTo(step.id)}
75+
>
76+
<Stepper.Title>{step.title}</Stepper.Title>
77+
{methods.when(step.id, (step) => {
78+
return (
79+
<Stepper.Panel className="space-y-4">
80+
{step.id === "step-1" && (
81+
<ConnectVercelForm.Plan />
82+
)}
83+
{step.id === "step-2" && (
84+
<ConnectVercelForm.Json />
85+
)}
86+
{step.id === "step-3" && (
87+
<>
88+
<p>
89+
Deploy your project to
90+
Vercel using your
91+
favorite method. After
92+
deployment, return here
93+
to add the endpoint.
94+
</p>
95+
</>
96+
)}
97+
{step.id === "step-4" && (
98+
<div>
99+
<ConnectVercelForm.Endpoint className="mb-2" />
100+
<ConnectVercelForm.ConnectionCheck />
101+
</div>
102+
)}
103+
<Stepper.Controls>
104+
<Button
105+
type="button"
106+
variant="secondary"
107+
onClick={methods.prev}
108+
disabled={methods.isFirst}
109+
>
110+
Previous
111+
</Button>
112+
<Button
113+
onClick={
114+
methods.isLast
115+
? onClose
116+
: methods.next
117+
}
118+
>
119+
{methods.isLast
120+
? "Done"
121+
: "Next"}
122+
</Button>
123+
</Stepper.Controls>
124+
</Stepper.Panel>
125+
);
126+
})}
127+
</Stepper.Step>
128+
))}
129+
</Stepper.Navigation>
130+
</>
131+
)}
132+
</Stepper.Provider>
133+
);
134+
}

frontend/src/app/forms/connect-railway-form.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,5 +118,3 @@ export function ConnectionCheck() {
118118
</AnimatePresence>
119119
);
120120
}
121-
122-
export { Preview } from "./connect-vercel-form";

frontend/src/app/forms/connect-vercel-form.tsx

Lines changed: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
1+
import { faCheck, faSpinnerThird, Icon } from "@rivet-gg/icons";
2+
import { useQuery } from "@tanstack/react-query";
3+
import { AnimatePresence, motion } from "framer-motion";
14
import { type UseFormReturn, useFormContext } from "react-hook-form";
25
import z from "zod";
36
import {
7+
CodeFrame,
8+
CodeGroup,
9+
CodePreview,
10+
cn,
411
createSchemaForm,
512
FormControl,
613
FormDescription,
@@ -9,6 +16,7 @@ import {
916
FormLabel,
1017
FormMessage,
1118
Input,
19+
ScrollArea,
1220
Select,
1321
SelectContent,
1422
SelectItem,
@@ -56,14 +64,58 @@ export const Plan = ({ className }: { className?: string }) => {
5664
</SelectContent>
5765
</Select>
5866
</FormControl>
59-
<FormDescription className="col-span-1"></FormDescription>
67+
<FormDescription className="col-span-1">
68+
Your Vercel plan determines the configuration required
69+
to properly run your Rivet Engine.
70+
</FormDescription>
6071
<FormMessage className="col-span-1" />
6172
</FormItem>
6273
)}
6374
/>
6475
);
6576
};
6677

78+
const PLAN_TO_MAX_DURATION: Record<string, number> = {
79+
hobby: 60,
80+
pro: 300,
81+
enterprise: 900,
82+
};
83+
84+
const code = ({ plan }: { plan: string }) =>
85+
`{
86+
"$schema": "https://openapi.vercel.sh/vercel.json",
87+
"fluid": false, // [!code highlight]
88+
"functions": {
89+
"**": {
90+
"maxDuration": ${PLAN_TO_MAX_DURATION[plan] || 60}, // [!code highlight]
91+
},
92+
},
93+
}`;
94+
95+
export const Json = () => {
96+
const { watch } = useFormContext<FormValues>();
97+
98+
const plan = watch("plan");
99+
return (
100+
<div className="space-y-2 mt-2">
101+
<CodeFrame language="json" title="vercel.json">
102+
<CodePreview
103+
className="w-full min-w-0"
104+
language="json"
105+
code={code({ plan })}
106+
/>
107+
</CodeFrame>
108+
<FormDescription className="col-span-1">
109+
<b>Max Duration</b> - The maximum execution time of your
110+
serverless functions.
111+
<br />
112+
<b>Disable Fluid Compute</b> - Rivet has its own intelligent
113+
load balancing mechanism.
114+
</FormDescription>
115+
</div>
116+
);
117+
};
118+
67119
export const Endpoint = ({ className }: { className?: string }) => {
68120
const { control } = useFormContext<FormValues>();
69121
return (
@@ -78,7 +130,6 @@ export const Endpoint = ({ className }: { className?: string }) => {
78130
<FormControl className="row-start-2">
79131
<Input
80132
placeholder="https://your-application.vercel.app"
81-
maxLength={25}
82133
{...field}
83134
/>
84135
</FormControl>
@@ -88,3 +139,68 @@ export const Endpoint = ({ className }: { className?: string }) => {
88139
/>
89140
);
90141
};
142+
143+
export function ConnectionCheck() {
144+
const { watch, formState } = useFormContext<FormValues>();
145+
const endpoint = watch("endpoint");
146+
const enabled = !!endpoint && z.string().url().safeParse(endpoint).success;
147+
148+
const { data } = useQuery({
149+
queryKey: ["vercel-endpoint-check", endpoint],
150+
queryFn: async () => {
151+
try {
152+
const url = new URL("/health", endpoint);
153+
const response = await fetch(url);
154+
if (!response.ok) {
155+
throw new Error("Failed to connect");
156+
}
157+
return response.json();
158+
} catch {
159+
const url = new URL("/api/rivet/health", endpoint);
160+
const response = await fetch(url);
161+
if (!response.ok) {
162+
throw new Error("Failed to connect");
163+
}
164+
return response.json();
165+
}
166+
},
167+
enabled,
168+
refetchInterval: 1000,
169+
});
170+
171+
const success = !!data;
172+
173+
return (
174+
<AnimatePresence>
175+
{enabled ? (
176+
<motion.div
177+
layoutId="msg"
178+
className={cn(
179+
"text-center text-muted-foreground text-sm overflow-hidden flex items-center justify-center",
180+
success && "text-primary-foreground",
181+
)}
182+
initial={{ height: 0, opacity: 0.5 }}
183+
animate={{ height: "4rem", opacity: 1 }}
184+
>
185+
{success ? (
186+
<>
187+
<Icon
188+
icon={faCheck}
189+
className="mr-1.5 text-primary"
190+
/>{" "}
191+
Runner successfully connected
192+
</>
193+
) : (
194+
<>
195+
<Icon
196+
icon={faSpinnerThird}
197+
className="mr-1.5 animate-spin"
198+
/>{" "}
199+
Waiting for runner to connect...
200+
</>
201+
)}
202+
</motion.div>
203+
) : null}
204+
</AnimatePresence>
205+
);
206+
}

frontend/src/components/code-preview/code-preview.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { transformerNotationHighlight } from "@shikijs/transformers";
12
import { useEffect, useMemo, useRef, useState } from "react";
23
import {
34
createHighlighterCore,
@@ -8,9 +9,14 @@ import {
89
import { Skeleton } from "../ui/skeleton";
910
import theme from "./theme.json";
1011

12+
const langs = {
13+
typescript: () => import("@shikijs/langs/typescript"),
14+
json: () => import("@shikijs/langs/json"),
15+
};
16+
1117
interface CodePreviewProps {
1218
code: string;
13-
language: "typescript";
19+
language: keyof typeof langs;
1420
className?: string;
1521
}
1622

@@ -24,7 +30,7 @@ export function CodePreview({ className, code, language }: CodePreviewProps) {
2430
async function createHighlighter() {
2531
highlighter.current ??= await createHighlighterCore({
2632
themes: [theme as ThemeInput],
27-
langs: [import("@shikijs/langs/typescript")],
33+
langs: [await langs[language]()],
2834
engine: createOnigurumaEngine(import("shiki/wasm")),
2935
});
3036
}
@@ -36,7 +42,7 @@ export function CodePreview({ className, code, language }: CodePreviewProps) {
3642
return () => {
3743
highlighter.current?.dispose();
3844
};
39-
}, []);
45+
}, [language]);
4046

4147
const result = useMemo(
4248
() =>
@@ -45,6 +51,7 @@ export function CodePreview({ className, code, language }: CodePreviewProps) {
4551
: (highlighter.current?.codeToHtml(code, {
4652
lang: language,
4753
theme: theme.name,
54+
transformers: [transformerNotationHighlight()],
4855
}) as TrustedHTML),
4956
[isLoading, code, language],
5057
);

frontend/src/components/code.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ export const CodeFrame = ({
104104
}: CodeFrameProps) => {
105105
return (
106106
<div className="not-prose my-4 rounded-lg border group-[.code-group]:my-0 group-[.code-group]:-mt-2 group-[.code-group]:border-none">
107-
<div className="bg-background text-wrap p-2 text-sm">
107+
<div className="bg-background text-wrap py-2 text-sm">
108108
<ScrollArea className="w-full">
109109
{children
110110
? cloneElement(children, { escaped: true })

0 commit comments

Comments
 (0)