Skip to content

Commit 6994abf

Browse files
Feat/ez terminal (#321)
adds a standalone terminal that starts and reconnects to a sandbox at /dashboard/terminal
1 parent 5fa2cea commit 6994abf

18 files changed

Lines changed: 1612 additions & 3 deletions

File tree

bun.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@
9898
"@vercel/kv": "^3.0.0",
9999
"@vercel/otel": "^1.13.0",
100100
"@vercel/speed-insights": "^1.2.0",
101+
"@xterm/xterm": "^6.0.0",
101102
"cheerio": "^1.0.0",
102103
"chrono-node": "^2.8.4",
103104
"class-variance-authority": "^0.7.1",
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import '@xterm/xterm/css/xterm.css'
2+
3+
export default function TerminalLayout({
4+
children,
5+
}: {
6+
children: React.ReactNode
7+
}) {
8+
return children
9+
}
Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
import Link from 'next/link'
2+
import type { Metadata } from 'next/types'
3+
import { SUPABASE_AUTH_HEADERS } from '@/configs/api'
4+
import { AUTH_URLS } from '@/configs/urls'
5+
import type { TeamModel } from '@/core/modules/teams/models'
6+
import { createUserTeamsRepository } from '@/core/modules/teams/user-teams-repository.server'
7+
import {
8+
createDefaultTemplatesRepository,
9+
createTemplatesRepository,
10+
} from '@/core/modules/templates/repository.server'
11+
import { getSessionInsecure } from '@/core/server/functions/auth/get-session'
12+
import getUserByToken from '@/core/server/functions/auth/get-user-by-token'
13+
import { resolveUserTeam } from '@/core/server/functions/team/resolve-user-team'
14+
import { infra } from '@/core/shared/clients/api'
15+
import { SandboxIdSchema } from '@/core/shared/schemas/api'
16+
import DashboardTerminal from '@/features/dashboard/terminal/dashboard-terminal'
17+
import { normalizeTerminalTemplate } from '@/features/dashboard/terminal/template'
18+
import { Button } from '@/ui/primitives/button'
19+
20+
export const metadata: Metadata = {
21+
title: 'Terminal - E2B',
22+
robots: 'noindex, nofollow',
23+
}
24+
25+
interface TerminalPageProps {
26+
searchParams: Promise<{
27+
command?: string
28+
sandboxId?: string
29+
template?: string
30+
}>
31+
}
32+
33+
export default async function TerminalPage({
34+
searchParams,
35+
}: TerminalPageProps) {
36+
const { command = '', sandboxId, template } = await searchParams
37+
const terminalTemplate = normalizeTerminalTemplate(template)
38+
const terminalSandboxId = normalizeTerminalSandboxId(sandboxId)
39+
40+
if (!terminalTemplate) {
41+
return <TerminalUnavailable message="The terminal template is invalid." />
42+
}
43+
44+
if (terminalSandboxId === null) {
45+
return <TerminalUnavailable message="The terminal sandbox ID is invalid." />
46+
}
47+
48+
const session = await getSessionInsecure()
49+
const { data, error } = await getUserByToken(session?.access_token)
50+
51+
if (error || !data.user || !session) {
52+
return (
53+
<TerminalSignIn
54+
sandboxId={terminalSandboxId}
55+
template={terminalTemplate}
56+
/>
57+
)
58+
}
59+
60+
const teamsRepository = createUserTeamsRepository({
61+
accessToken: session.access_token,
62+
})
63+
const teamsResult = await teamsRepository.listUserTeams()
64+
65+
if (!teamsResult.ok) {
66+
return <TerminalUnavailable />
67+
}
68+
69+
const resolvedTeam = await resolveUserTeam(data.user.id, session.access_token)
70+
const team = terminalSandboxId
71+
? await resolveTerminalSandboxTeam({
72+
accessToken: session.access_token,
73+
preferredTeamId: resolvedTeam?.id,
74+
sandboxId: terminalSandboxId,
75+
teams: teamsResult.data,
76+
})
77+
: teamsResult.data.find((candidate) => candidate.id === resolvedTeam?.id)
78+
79+
if (!team) {
80+
return <TerminalUnavailable />
81+
}
82+
83+
const templateAvailable = terminalSandboxId
84+
? { ok: true as const, available: true }
85+
: await isTerminalTemplateAvailable({
86+
accessToken: session.access_token,
87+
teamId: team.id,
88+
template: terminalTemplate,
89+
})
90+
91+
if (!templateAvailable.ok) {
92+
return (
93+
<TerminalUnavailable message="We could not verify the terminal template for this account." />
94+
)
95+
}
96+
97+
if (!templateAvailable.available) {
98+
return (
99+
<TerminalUnavailable
100+
message={`Template "${terminalTemplate}" is not available for this account.`}
101+
/>
102+
)
103+
}
104+
105+
return (
106+
<main className="h-dvh min-h-[360px] bg-bg p-3">
107+
<DashboardTerminal
108+
autoStart
109+
initialCommand={command}
110+
initialSandboxId={terminalSandboxId}
111+
initialTemplate={terminalTemplate}
112+
teamId={team.id}
113+
/>
114+
</main>
115+
)
116+
}
117+
118+
function normalizeTerminalSandboxId(sandboxId?: string) {
119+
const value = sandboxId?.trim()
120+
if (!value) return undefined
121+
122+
const parsedSandboxId = SandboxIdSchema.safeParse(value)
123+
return parsedSandboxId.success ? parsedSandboxId.data : null
124+
}
125+
126+
async function resolveTerminalSandboxTeam({
127+
accessToken,
128+
preferredTeamId,
129+
sandboxId,
130+
teams,
131+
}: {
132+
accessToken: string
133+
preferredTeamId?: string
134+
sandboxId: string
135+
teams: TeamModel[]
136+
}) {
137+
if (preferredTeamId) {
138+
const preferredTeam = teams.find((team) => team.id === preferredTeamId)
139+
if (
140+
preferredTeam &&
141+
(await hasSandboxInTeam({
142+
accessToken,
143+
sandboxId,
144+
teamId: preferredTeam.id,
145+
}))
146+
) {
147+
return preferredTeam
148+
}
149+
}
150+
151+
const candidateTeams = teams.filter((team) => team.id !== preferredTeamId)
152+
const teamMatches = await Promise.all(
153+
candidateTeams.map(async (team) => ({
154+
team,
155+
ownsSandbox: await hasSandboxInTeam({
156+
accessToken,
157+
sandboxId,
158+
teamId: team.id,
159+
}),
160+
}))
161+
)
162+
163+
return teamMatches.find((match) => match.ownsSandbox)?.team ?? null
164+
}
165+
166+
async function hasSandboxInTeam({
167+
accessToken,
168+
sandboxId,
169+
teamId,
170+
}: {
171+
accessToken: string
172+
sandboxId: string
173+
teamId: string
174+
}) {
175+
try {
176+
const result = await infra.GET('/sandboxes/{sandboxID}', {
177+
params: {
178+
path: {
179+
sandboxID: sandboxId,
180+
},
181+
},
182+
headers: {
183+
...SUPABASE_AUTH_HEADERS(accessToken, teamId),
184+
},
185+
cache: 'no-store',
186+
})
187+
188+
return result.response.ok && Boolean(result.data)
189+
} catch {
190+
return false
191+
}
192+
}
193+
194+
async function isTerminalTemplateAvailable({
195+
accessToken,
196+
teamId,
197+
template,
198+
}: {
199+
accessToken: string
200+
teamId: string
201+
template: string
202+
}) {
203+
if (template === 'base') {
204+
return { ok: true as const, available: true }
205+
}
206+
207+
const defaultTemplatesRepository = createDefaultTemplatesRepository({
208+
accessToken,
209+
})
210+
const teamTemplatesRepository = createTemplatesRepository({
211+
accessToken,
212+
teamId,
213+
})
214+
const [defaultTemplates, teamTemplates] = await Promise.all([
215+
defaultTemplatesRepository.getDefaultTemplatesCached(),
216+
teamTemplatesRepository.getTeamTemplates(),
217+
])
218+
219+
if (!defaultTemplates.ok || !teamTemplates.ok) {
220+
return { ok: false as const }
221+
}
222+
223+
const templates = [
224+
...defaultTemplates.data.templates,
225+
...teamTemplates.data.templates,
226+
]
227+
228+
return {
229+
ok: true as const,
230+
available: templates.some((candidate) =>
231+
[
232+
candidate.templateID,
233+
...(candidate.aliases ?? []),
234+
...(candidate.names ?? []),
235+
].includes(template)
236+
),
237+
}
238+
}
239+
240+
function TerminalSignIn({
241+
sandboxId,
242+
template,
243+
}: {
244+
sandboxId?: string
245+
template: string
246+
}) {
247+
const returnToParams = new URLSearchParams()
248+
249+
if (template) {
250+
returnToParams.set('template', template)
251+
}
252+
253+
if (sandboxId) {
254+
returnToParams.set('sandboxId', sandboxId)
255+
}
256+
257+
const returnToQuery = returnToParams.toString()
258+
const returnTo = `/dashboard/terminal${
259+
returnToQuery ? `?${returnToQuery}` : ''
260+
}`
261+
const signInHref = `${AUTH_URLS.SIGN_IN}?${new URLSearchParams({
262+
returnTo,
263+
}).toString()}`
264+
265+
return (
266+
<main className="flex h-dvh min-h-[360px] items-center justify-center bg-bg p-6">
267+
<div className="flex max-w-sm flex-col items-center gap-4 text-center">
268+
<div>
269+
<h1 className="text-lg font-medium">Sign in to open a terminal</h1>
270+
<p className="text-fg-secondary mt-2 text-sm">
271+
The terminal runs in your E2B dashboard account.
272+
</p>
273+
</div>
274+
<Button asChild>
275+
<Link href={signInHref} target="_top">
276+
Sign in
277+
</Link>
278+
</Button>
279+
</div>
280+
</main>
281+
)
282+
}
283+
284+
function TerminalUnavailable({
285+
message = 'We could not resolve a dashboard team for this account.',
286+
}: {
287+
message?: string
288+
}) {
289+
return (
290+
<main className="flex h-dvh min-h-[360px] items-center justify-center bg-bg p-6">
291+
<div className="max-w-sm text-center">
292+
<h1 className="text-lg font-medium">Terminal unavailable</h1>
293+
<p className="text-fg-secondary mt-2 text-sm">{message}</p>
294+
</div>
295+
</main>
296+
)
297+
}

src/core/server/api/middlewares/auth.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { context, SpanStatusCode, trace } from '@opentelemetry/api'
22
import {
33
createServerClient,
44
parseCookieHeader,
5+
type SetAllCookies,
56
serializeCookieHeader,
67
} from '@supabase/ssr'
78
import { unauthorizedUserError } from '@/core/server/adapters/errors'
@@ -19,7 +20,7 @@ const createSupabaseServerClient = (headers: Headers) => {
1920
getAll() {
2021
return parseCookieHeader(headers.get('cookie') ?? '')
2122
},
22-
setAll(cookiesToSet) {
23+
setAll(cookiesToSet: Parameters<SetAllCookies>[0]) {
2324
cookiesToSet.forEach(({ name, value, options }) => {
2425
headers.append(
2526
'Set-Cookie',

src/core/server/http/proxy.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ export function isDashboardRoute(pathname: string): boolean {
1515
return pathname.startsWith(PROTECTED_URLS.DASHBOARD)
1616
}
1717

18+
function isDashboardTerminalRoute(pathname: string): boolean {
19+
return (
20+
pathname === '/dashboard/terminal' || pathname === '/dashboard/terminal/'
21+
)
22+
}
23+
1824
export function buildRedirectUrl(path: string, request: NextRequest): URL {
1925
return new URL(path, request.url)
2026
}
@@ -23,7 +29,11 @@ export function getAuthRedirect(
2329
request: NextRequest,
2430
isAuthenticated: boolean
2531
): NextResponse | null {
26-
if (isDashboardRoute(request.nextUrl.pathname) && !isAuthenticated) {
32+
if (
33+
isDashboardRoute(request.nextUrl.pathname) &&
34+
!isDashboardTerminalRoute(request.nextUrl.pathname) &&
35+
!isAuthenticated
36+
) {
2737
return NextResponse.redirect(buildRedirectUrl(AUTH_URLS.SIGN_IN, request))
2838
}
2939

src/features/dashboard/layouts/header.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export default function DashboardLayoutHeader({
5454
)}
5555
</div>
5656

57-
<ClientOnly className="flex items-center pl-2 pr-2">
57+
<ClientOnly className="flex items-center gap-2 pl-2 pr-2">
5858
<ThemeSwitcher />
5959
</ClientOnly>
6060

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export const TERMINAL_SANDBOX_TIMEOUT_MS = 30 * 60 * 1000
2+
export const DEFAULT_COLS = 100
3+
export const DEFAULT_ROWS = 28
4+
export const DEFAULT_PANEL_HEIGHT = 260
5+
export const MAX_TERMINAL_TRANSCRIPT_CHARS = 200_000
6+
export const TERMINAL_SESSION_STORAGE_PREFIX = 'dashboard-terminal-session'
7+
export const DEFAULT_CWD = '/home/user'

0 commit comments

Comments
 (0)