Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions app/api/lessons/[slug]/prepare/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { getLesson } from "@/lib/lessons";
import {
ensureBranchForLesson,
resetBranchForLesson,
} from "@/lib/branch-manager";
import { RateLimitError } from "@/lib/rate-limit";

type Ctx = { params: Promise<{ slug: string }> };

/**
* Prepare (or reset) the sandbox branch for a lesson, streaming progress as
* newline-delimited JSON so the client can show the distinct branching and
* seeding stages live. Each line is one event:
* { "phase": "branching" }
* { "phase": "seeding" }
* { "phase": "ready", "branch": { "name": "…" } }
* { "phase": "error", "message": "…", "retryAfterSeconds"?: number }
*/
export async function POST(req: Request, ctx: Ctx) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}

const { slug } = await ctx.params;
const lesson = await getLesson(slug);
if (!lesson) {
return NextResponse.json({ error: "Lesson not found" }, { status: 404 });
}

if (
!process.env.XATA_API_KEY ||
!process.env.XATA_ORG_ID ||
!process.env.XATA_PROJECT_ID
) {
return NextResponse.json(
{ error: "Sandbox is not configured." },
{ status: 503 },
);
}

let body: { reset?: unknown } = {};
try {
body = await req.json();
} catch {
// No body is fine — treat as a non-reset prepare.
}
const reset = body?.reset === true;
const userId = session.user.id;

const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const send = (event: Record<string, unknown>) => {
controller.enqueue(encoder.encode(JSON.stringify(event) + "\n"));
};
try {
const onPhase = (phase: "branching" | "seeding") =>
send({ phase });
const row = reset
? await resetBranchForLesson(userId, lesson, onPhase)
: await ensureBranchForLesson(userId, lesson, onPhase);

// The lesson page reads the branch row in a server component, so the
// RSC payload needs invalidating before the client's router.refresh()
// will pick up the new branch. Guard it: a failure here shouldn't turn
// an already-successful prepare into an error event.
try {
revalidatePath(`/lessons/${slug}`);
} catch {
// best-effort
}
send({ phase: "ready", branch: { name: row.xataBranchName } });
} catch (err) {
if (err instanceof RateLimitError) {
send({
phase: "error",
message: err.message,
retryAfterSeconds: err.retryAfterSeconds,
});
} else {
send({ phase: "error", message: (err as Error).message });
}
} finally {
controller.close();
}
},
});

return new Response(stream, {
headers: {
"Content-Type": "application/x-ndjson; charset=utf-8",
"Cache-Control": "no-store, no-transform",
"X-Content-Type-Options": "nosniff",
},
});
}
48 changes: 0 additions & 48 deletions app/api/lessons/[slug]/reset/route.ts

This file was deleted.

76 changes: 76 additions & 0 deletions components/lesson/SandboxBootstrap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"use client";

import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { usePrepareStream } from "./usePrepareStream";
import { SandboxPrepProgress } from "./SandboxPrepProgress";

/**
* Rendered the first time a lesson is opened (no ready branch yet). Drives the
* streaming /prepare endpoint so the user watches the branch get forked and
* seeded, then refreshes the server tree to swap in the interactive panel.
*/
export function SandboxBootstrap({ lessonSlug }: { lessonSlug: string }) {
const router = useRouter();
const { phase, error, run } = usePrepareStream(lessonSlug);
const started = useRef(false);

useEffect(() => {
if (started.current) return;
started.current = true;
run().then((res) => {
if (res.ok) router.refresh();
});
}, [run, router]);

const onRetry = () => {
run().then((res) => {
if (res.ok) router.refresh();
});
};

return (
<>
<div className="flex items-center justify-between gap-3 rounded-lg border border-black/10 bg-zinc-50 px-3 py-2 text-xs dark:border-white/10 dark:bg-zinc-900/40">
<span className="flex min-w-0 items-center gap-2 text-zinc-500">
<span
aria-hidden
className={`inline-block h-1.5 w-1.5 shrink-0 rounded-full ${
phase === "error"
? "bg-rose-500"
: "animate-pulse bg-amber-500"
}`}
/>
<span className="font-mono text-zinc-700 dark:text-zinc-300">
preparing sandbox…
</span>
</span>
</div>
<div className="relative h-[60dvh] min-h-[320px] lg:h-auto lg:flex-1 lg:min-h-0">
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg border border-black/10 bg-zinc-950/60 backdrop-blur-[2px] dark:border-white/10">
{phase === "error" ? (
<div className="mx-4 max-w-sm rounded-md border border-rose-500/30 bg-zinc-900 p-4 text-sm text-zinc-100 shadow-lg">
<div className="text-xs font-medium uppercase tracking-wide text-rose-300">
Couldn&apos;t prepare your sandbox
</div>
<p className="mt-2 break-words font-mono text-xs text-rose-200">
{error}
</p>
<button
type="button"
onClick={onRetry}
className="mt-3 rounded-md border border-white/15 px-2.5 py-1 text-xs font-medium hover:bg-white/10"
>
Try again
</button>
</div>
) : (
<div className="rounded-md border border-white/10 bg-zinc-900 px-4 py-3 text-zinc-100 shadow-lg">
<SandboxPrepProgress phase={phase ?? "branching"} />
</div>
)}
</div>
</div>
</>
);
}
2 changes: 1 addition & 1 deletion components/lesson/SandboxLoading.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export function SandboxLoading() {
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg border border-black/10 bg-zinc-950/60 backdrop-blur-[2px] dark:border-white/10">
<div className="flex items-center gap-2 rounded-md border border-white/10 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 shadow-lg">
<Spinner />
<span>Forking a fresh Postgres branch…</span>
<span>Loading sandbox…</span>
</div>
</div>
</div>
Expand Down
49 changes: 21 additions & 28 deletions components/lesson/SandboxPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"use client";

import { useState, useTransition } from "react";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { Terminal } from "@/components/shell/Terminal";
import { usePrepareStream } from "./usePrepareStream";
import { SandboxPrepProgress } from "./SandboxPrepProgress";

type Props = {
lessonSlug: string;
Expand All @@ -11,39 +13,24 @@ type Props = {

export function SandboxPanel({ lessonSlug, branchName }: Props) {
const router = useRouter();
const [fetching, setFetching] = useState(false);
const { phase, error, run, clear } = usePrepareStream(lessonSlug);
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);

// fetching: the POST is in flight.
// isPending: router.refresh() is re-rendering the server tree.
// Either way the user sees the busy state.
const busy = fetching || isPending;
// While the stream runs, `phase` is set (branching/seeding/ready). Once it's
// ready we clear it and hand off to isPending, which stays true until the
// refreshed RSC payload — with the new branch — has rendered.
const streaming = phase !== null && phase !== "error";
const busy = streaming || isPending;

const onClearShell = () => {
window.dispatchEvent(new CustomEvent("learn:clear-shell"));
};

const onReset = async () => {
setError(null);
setFetching(true);
try {
const res = await fetch(
`/api/lessons/${encodeURIComponent(lessonSlug)}/reset`,
{ method: "POST" },
);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
setError(body.error ?? `Reset failed (${res.status})`);
return;
}
// Hand off the busy state to isPending so the spinner stays up until
// the refreshed RSC payload has rendered.
const res = await run({ reset: true });
if (res.ok) {
clear();
startTransition(() => router.refresh());
} catch (err) {
setError((err as Error).message);
} finally {
setFetching(false);
}
};

Expand Down Expand Up @@ -89,9 +76,15 @@ export function SandboxPanel({ lessonSlug, branchName }: Props) {
<Terminal key={branchName} lessonSlug={lessonSlug} />
{busy && (
<div className="pointer-events-auto absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-zinc-950/60 backdrop-blur-[2px]">
<div className="flex items-center gap-2 rounded-md border border-white/10 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 shadow-lg">
<Spinner />
<span>Resetting sandbox…</span>
<div className="rounded-md border border-white/10 bg-zinc-900 px-4 py-3 text-sm text-zinc-100 shadow-lg">
{streaming ? (
<SandboxPrepProgress phase={phase ?? "branching"} />
) : (
<span className="flex items-center gap-2">
<Spinner />
Resetting sandbox…
</span>
)}
</div>
</div>
)}
Expand Down
Loading
Loading