diff --git a/.agents/skills/btst-client-plugin-dev/SKILL.md b/.agents/skills/btst-client-plugin-dev/SKILL.md index b70c07da..5e10f7b9 100644 --- a/.agents/skills/btst-client-plugin-dev/SKILL.md +++ b/.agents/skills/btst-client-plugin-dev/SKILL.md @@ -11,6 +11,7 @@ description: Patterns for writing BTST client plugins inside the monorepo, inclu src/plugins/{name}/ client/ plugin.tsx ← defineClientPlugin entry + hooks.ts ← "use client" React hooks only components/ pages/ my-page.tsx ← wrapper: ComposedRoute + lazy import @@ -18,6 +19,22 @@ src/plugins/{name}/ query-keys.ts ← React Query key factory ``` +## Server/client module boundary + +`client/plugin.tsx` must stay import-safe on the server. Next.js (including SSG build) +can execute `createStackClient()` on the server, which calls each `*ClientPlugin()` +factory. If that module is marked `"use client"` or imports a client-only module, build +can fail with "Attempted to call ... from the server". + +Rules: + +- Do **not** add `"use client"` to `client/plugin.tsx`. +- Keep `client/plugin.tsx` free of React hooks (`useState`, `useEffect`, etc.). +- Put hook utilities in a separate client-only module (`client/hooks.ts`) with + `"use client"`, and re-export them from `client/index.ts`. +- UI components can remain client components as needed; only the plugin factory entry + must stay server-import-safe. + ## Route anatomy Each route returns exactly three things: @@ -110,6 +127,7 @@ type PluginOverrides = { - **Next.js Link href undefined** — use `href={href || "#"}` pattern. - **Suspense errors not caught** — add `if (error && !isFetching) throw error` in every suspense hook. - **Missing ComposedRoute wrapper** — without it, errors crash the entire app instead of hitting ErrorBoundary. +- **Client directive on `client/plugin.tsx`** — can break SSG/SSR when plugin factories are invoked server-side. ## Full code patterns diff --git a/packages/cli/build.config.ts b/packages/cli/build.config.ts index c4e7b6ab..335ae60a 100644 --- a/packages/cli/build.config.ts +++ b/packages/cli/build.config.ts @@ -4,7 +4,7 @@ export default defineBuildConfig({ declaration: true, clean: true, outDir: "dist", - entries: ["./src/index.ts"], + entries: ["./src/index.ts", "./src/lib.ts"], rollup: { emitCJS: true, esbuild: { diff --git a/packages/cli/package.json b/packages/cli/package.json index b4d0775d..f7cf332f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -24,6 +24,16 @@ "main": "./dist/index.cjs", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.cjs" + }, + "./lib": { + "import": "./dist/lib.mjs", + "require": "./dist/lib.cjs" + } + }, "files": [ "dist", "src", diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 4b3eb3b8..2a109c8e 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -15,6 +15,7 @@ import { ADAPTERS, DEFAULT_PLUGIN_SELECTION, PLUGINS, + PLUGIN_ROUTES, } from "../utils/constants"; import { detectAlias } from "../utils/detect-alias"; import { detectCssFile } from "../utils/detect-css-file"; @@ -346,12 +347,20 @@ export function createInitCommand() { ? "yes" : "manual action may be needed"; + const scaffoldedRoutes = selectedPlugins.flatMap( + (p) => PLUGIN_ROUTES[p] ?? [], + ); + const routesList = + scaffoldedRoutes.length > 0 + ? `\nAvailable routes:\n${scaffoldedRoutes.map((r) => ` ${r}`).join("\n")}\n` + : ""; + outro(`BTST init complete. Files written: ${writeResult.written.length} Files skipped: ${writeResult.skipped.length} CSS updated: ${cssPatch.updated ? "yes" : "no"} Layout patched: ${layoutStatus} - +${routesList} Next steps: - Verify routes under /pages/* - Run your build diff --git a/packages/cli/src/lib.ts b/packages/cli/src/lib.ts new file mode 100644 index 00000000..5a8dbf6b --- /dev/null +++ b/packages/cli/src/lib.ts @@ -0,0 +1,15 @@ +/** + * Programmatic API for @btst/codegen scaffold utilities. + * Use this when consuming the CLI as a library (e.g. in the playground). + */ +export { buildScaffoldPlan } from "./utils/scaffold-plan"; +export { PLUGINS, ADAPTERS } from "./utils/constants"; +export { PLUGIN_ROUTES } from "./utils/plugin-routes"; +export type { + PluginKey, + Adapter, + Framework, + FileWritePlanItem, + ScaffoldPlan, +} from "./types"; +export type { PluginMeta, AdapterMeta } from "./utils/constants"; diff --git a/packages/cli/src/utils/constants.ts b/packages/cli/src/utils/constants.ts index 377fdde7..970cf4a4 100644 --- a/packages/cli/src/utils/constants.ts +++ b/packages/cli/src/utils/constants.ts @@ -158,3 +158,45 @@ export const PLUGINS: readonly PluginMeta[] = [ ]; export const DEFAULT_PLUGIN_SELECTION: PluginKey[] = []; + +/** + * Maps each plugin key to the list of /pages/* route paths it registers. + * Paths are verified against each plugin's client/plugin.tsx createRoute() calls. + * All page routes are prefixed with /pages (matching siteBasePath="/pages"). + * Non-page routes (API-only plugins) are listed separately. + */ +export const PLUGIN_ROUTES: Record = { + blog: [ + "/pages/blog", + "/pages/blog/drafts", + "/pages/blog/new", + "/pages/blog/:slug/edit", + "/pages/blog/tag/:tagSlug", + "/pages/blog/:slug", + ], + "ai-chat": ["/pages/chat", "/pages/chat/:id"], + cms: [ + "/pages/cms", + "/pages/cms/:typeSlug", + "/pages/cms/:typeSlug/new", + "/pages/cms/:typeSlug/:id", + ], + "form-builder": [ + "/pages/forms", + "/pages/forms/new", + "/pages/forms/:id/edit", + "/pages/forms/:id/submissions", + ], + "ui-builder": [ + "/pages/ui-builder", + "/pages/ui-builder/new", + "/pages/ui-builder/:id/edit", + ], + kanban: ["/pages/kanban", "/pages/kanban/new", "/pages/kanban/:boardId"], + comments: ["/pages/comments/moderation", "/pages/comments"], + media: ["/pages/media"], + "route-docs": ["/pages/route-docs"], + /** open-api registers an API route, not a page route */ + "open-api": ["/api/data/reference"], + "better-auth-ui": ["/pages/auth", "/pages/account/settings", "/pages/org"], +}; diff --git a/packages/cli/src/utils/plugin-routes.ts b/packages/cli/src/utils/plugin-routes.ts new file mode 100644 index 00000000..a74f7c64 --- /dev/null +++ b/packages/cli/src/utils/plugin-routes.ts @@ -0,0 +1,5 @@ +/** + * Re-export PLUGIN_ROUTES from constants so it can be imported + * from either location without bundling issues. + */ +export { PLUGIN_ROUTES } from "./constants"; diff --git a/packages/cli/src/utils/render-template.ts b/packages/cli/src/utils/render-template.ts index 0f42de92..dd47d059 100644 --- a/packages/cli/src/utils/render-template.ts +++ b/packages/cli/src/utils/render-template.ts @@ -19,6 +19,9 @@ export async function renderTemplate( join(__dirname, "..", "templates"), join(__dirname, "..", "src", "templates"), join(__dirname, "src", "templates"), + // When bundled into dist/shared/, go up two levels to reach package root src/templates + join(__dirname, "..", "..", "src", "templates"), + join(__dirname, "..", "..", "templates"), ]; let source: string | null = null; diff --git a/packages/stack/src/plugins/route-docs/client/hooks.ts b/packages/stack/src/plugins/route-docs/client/hooks.ts new file mode 100644 index 00000000..dbf97426 --- /dev/null +++ b/packages/stack/src/plugins/route-docs/client/hooks.ts @@ -0,0 +1,20 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { getRegisteredRoutes, type RegisteredRoute } from "./plugin"; + +/** + * React hook that returns all registered client route paths. + * Updates whenever the component mounts (after client hydration). + */ +export function useRegisteredRoutes(): RegisteredRoute[] { + const [routes, setRoutes] = useState(() => + getRegisteredRoutes(), + ); + + useEffect(() => { + setRoutes(getRegisteredRoutes()); + }, []); + + return routes; +} diff --git a/packages/stack/src/plugins/route-docs/client/index.ts b/packages/stack/src/plugins/route-docs/client/index.ts index a513a656..fbfa7e2b 100644 --- a/packages/stack/src/plugins/route-docs/client/index.ts +++ b/packages/stack/src/plugins/route-docs/client/index.ts @@ -4,4 +4,7 @@ export { ROUTE_DOCS_QUERY_KEY, generateSchema, getStoredContext, + getRegisteredRoutes, + type RegisteredRoute, } from "./plugin"; +export { useRegisteredRoutes } from "./hooks"; diff --git a/packages/stack/src/plugins/route-docs/client/plugin.tsx b/packages/stack/src/plugins/route-docs/client/plugin.tsx index 178c6f92..38a4674c 100644 --- a/packages/stack/src/plugins/route-docs/client/plugin.tsx +++ b/packages/stack/src/plugins/route-docs/client/plugin.tsx @@ -42,6 +42,49 @@ export function getStoredContext(): ClientStackContext | null { return moduleStoredContext; } +/** + * A registered route entry + */ +export interface RegisteredRoute { + /** The route path pattern (e.g., "/blog/:slug") */ + path: string; + /** The plugin this route belongs to (e.g., "blog") */ + plugin: string; + /** The route key within the plugin (e.g., "detail") */ + key: string; +} + +/** + * Returns all registered client route paths from the stored ClientStackContext. + * The context is populated when `createStackClient` is called (i.e. on first render). + * Returns an empty array if called before the stack client has been initialised. + */ +export function getRegisteredRoutes(): RegisteredRoute[] { + if (!moduleStoredContext) return []; + const result: RegisteredRoute[] = []; + for (const [pluginKey, plugin] of Object.entries( + moduleStoredContext.plugins, + )) { + if (pluginKey === "routeDocs" || plugin.name === "route-docs") continue; + try { + const routes = plugin.routes(moduleStoredContext); + for (const [routeKey, route] of Object.entries(routes)) { + const path = (route as any)?.path; + if (path) { + result.push({ + path, + plugin: plugin.name || pluginKey, + key: routeKey, + }); + } + } + } catch { + // silently skip plugins whose routes() throws during introspection + } + } + return result; +} + /** * Generate the route docs schema from the stored context * This can be called from both server and client diff --git a/playground/next-env.d.ts b/playground/next-env.d.ts new file mode 100644 index 00000000..c4b7818f --- /dev/null +++ b/playground/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/dev/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/playground/next.config.mjs b/playground/next.config.mjs new file mode 100644 index 00000000..f24f265c --- /dev/null +++ b/playground/next.config.mjs @@ -0,0 +1,23 @@ +/** @type {import('next').NextConfig} */ +const config = { + reactStrictMode: true, + basePath: "/playground", + assetPrefix: "/playground", + serverExternalPackages: ["handlebars"], + async headers() { + return [ + { + // COOP: same-origin + COEP: credentialless = cross-origin isolation. + // Required for SharedArrayBuffer, which WebContainers (template: "node") + // needs to boot. same-origin-allow-popups does NOT provide isolation. + source: "/(.*)", + headers: [ + { key: "Cross-Origin-Opener-Policy", value: "same-origin" }, + { key: "Cross-Origin-Embedder-Policy", value: "credentialless" }, + ], + }, + ]; + }, +}; + +export default config; diff --git a/playground/package.json b/playground/package.json new file mode 100644 index 00000000..6736437d --- /dev/null +++ b/playground/package.json @@ -0,0 +1,27 @@ +{ + "name": "btst-playground", + "version": "0.0.0", + "private": true, + "scripts": { + "build": "next build", + "dev": "next dev --port 3002", + "start": "next start --port 3002", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@btst/codegen": "workspace:*", + "@stackblitz/sdk": "^1.9.0", + "next": "16.0.10", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.1.10", + "@types/node": "^24.0.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.10", + "typescript": "^5.8.3" + } +} diff --git a/playground/postcss.config.mjs b/playground/postcss.config.mjs new file mode 100644 index 00000000..017b34b9 --- /dev/null +++ b/playground/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; diff --git a/playground/src/app/actions.ts b/playground/src/app/actions.ts new file mode 100644 index 00000000..a10b068c --- /dev/null +++ b/playground/src/app/actions.ts @@ -0,0 +1,56 @@ +"use server"; + +import { buildScaffoldPlan, PLUGINS, PLUGIN_ROUTES } from "@btst/codegen/lib"; +import type { PluginKey, FileWritePlanItem } from "@btst/codegen/lib"; + +export interface GenerateResult { + files: FileWritePlanItem[]; + routes: string[]; + cssImports: string[]; + extraPackages: string[]; +} + +export async function generateProject( + plugins: PluginKey[], +): Promise { + // ui-builder requires cms + const selectedPlugins: PluginKey[] = + plugins.includes("ui-builder") && !plugins.includes("cms") + ? ["cms", ...plugins] + : plugins; + + // Always include route-docs so users can see all available routes + const withRouteDocs: PluginKey[] = selectedPlugins.includes("route-docs") + ? selectedPlugins + : [...selectedPlugins, "route-docs"]; + + const plan = await buildScaffoldPlan({ + framework: "nextjs", + adapter: "memory", + plugins: withRouteDocs, + alias: "@/", + cssFile: "app/globals.css", + }); + + const cssImports = PLUGINS.filter((p) => + withRouteDocs.includes(p.key as PluginKey), + ) + .map((p) => p.cssImport) + .filter((c): c is string => Boolean(c)); + + const routes = withRouteDocs.flatMap((p) => PLUGIN_ROUTES[p] ?? []); + const extraPackages = Array.from( + new Set( + PLUGINS.filter((p) => withRouteDocs.includes(p.key as PluginKey)).flatMap( + (p) => p.extraPackages ?? [], + ), + ), + ); + + return { + files: plan.files, + routes, + cssImports, + extraPackages, + }; +} diff --git a/playground/src/app/globals.css b/playground/src/app/globals.css new file mode 100644 index 00000000..f1d8c73c --- /dev/null +++ b/playground/src/app/globals.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/playground/src/app/layout.tsx b/playground/src/app/layout.tsx new file mode 100644 index 00000000..420be6b4 --- /dev/null +++ b/playground/src/app/layout.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; +import "./globals.css"; + +export const metadata: Metadata = { + title: { + default: "BTST Playground", + template: "%s | BTST Playground", + }, + description: + "Build a BTST project in your browser — select plugins and see them live in a StackBlitz WebContainer.", +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +} diff --git a/playground/src/app/page.tsx b/playground/src/app/page.tsx new file mode 100644 index 00000000..5cf09f18 --- /dev/null +++ b/playground/src/app/page.tsx @@ -0,0 +1,63 @@ +import { PlaygroundClient } from "@/components/playground-client"; +import { PLUGINS } from "@btst/codegen/lib"; + +export default function PlaygroundPage() { + return ( +
+ {/* Header */} +
+
+
+
+ BTST + / + + Playground + +
+ + WebContainer + +
+ +
+
+ + {/* Main */} +
+ +
+ + {/* Footer */} +
+ Powered by{" "} + + BTST + {" "} + · StackBlitz WebContainer +
+
+ ); +} diff --git a/playground/src/components/playground-client.tsx b/playground/src/components/playground-client.tsx new file mode 100644 index 00000000..867dd679 --- /dev/null +++ b/playground/src/components/playground-client.tsx @@ -0,0 +1,279 @@ +"use client"; + +import { useState, useTransition, useCallback } from "react"; +import type { + PluginMeta, + PluginKey, + FileWritePlanItem, +} from "@btst/codegen/lib"; +import { PLUGIN_ROUTES } from "@btst/codegen/lib"; +import { generateProject } from "@/app/actions"; +import { PluginSelector } from "./plugin-selector"; +import { RouteList } from "./route-list"; +import { StackBlitzEmbed } from "./stackblitz-embed"; + +interface PlaygroundClientProps { + plugins: readonly PluginMeta[]; +} + +type View = "configure" | "preview"; + +interface GeneratedState { + files: FileWritePlanItem[]; + routes: string[]; + cssImports: string[]; + extraPackages: string[]; +} + +export function PlaygroundClient({ plugins }: PlaygroundClientProps) { + const [selected, setSelected] = useState(["blog"]); + const [view, setView] = useState("configure"); + const [generated, setGenerated] = useState(null); + const [activePreviewRoute, setActivePreviewRoute] = useState( + null, + ); + const [isPending, startTransition] = useTransition(); + const selectedCount = selected.includes("route-docs") + ? selected.length + : selected.length + 1; + + const handleLaunch = useCallback(() => { + startTransition(async () => { + const result = await generateProject(selected); + setGenerated(result); + const firstPageRoute = result.routes.find((route) => + route.startsWith("/pages/"), + ); + setActivePreviewRoute(firstPageRoute ?? null); + setView("preview"); + }); + }, [selected]); + + const handleBack = useCallback(() => { + setView("configure"); + setActivePreviewRoute(null); + }, []); + + // Preview routes are either from the generated state or derived from selection + const previewRoutes = generated?.routes ?? []; + const handlePreviewRouteClick = useCallback((route: string) => { + if (!route.startsWith("/pages/")) return; + setActivePreviewRoute(route); + }, []); + + return ( +
+ {view === "configure" ? ( + <> + {/* Hero */} +
+

+ Build a BTST project + in your browser +

+

+ Select the plugins you want, then launch a live preview powered by + StackBlitz WebContainers — no install required. +

+
+ + {/* Two-column layout: selector + route preview */} +
+ {/* Plugin selector */} +
+
+
+
+

Select plugins

+

+ {selectedCount} selected · route-docs always included +

+
+
+ + | + +
+
+ +
+
+ + {/* Route preview sidebar */} +
+
+

+ Available routes +

+ +
+ + {/* Launch button */} + + + {/* CLI hint */} +
+

+ Or scaffold locally with the CLI: +

+ + npx @btst/codegen@latest init + +
+
+
+ + ) : ( + <> + {/* Preview header */} +
+
+

Live preview

+

+ Running in StackBlitz WebContainer · next dev +

+
+
+ +
+
+ + {/* Embed + route list */} +
+ {/* StackBlitz embed */} +
+ {generated && ( + + )} +
+ + {/* Route list sidebar */} +
+

Available routes

+ +
+

+ Navigate inside the preview to: +

+ + /pages/route-docs + +

+ to see all routes live. +

+
+
+
+ + )} +
+ ); +} + +// Derive routes from the current plugin selection before generating +function getRoutesForSelection(selected: PluginKey[]): string[] { + const selectedPlugins: PluginKey[] = + selected.includes("ui-builder") && !selected.includes("cms") + ? ["cms", ...selected] + : selected; + const withRouteDocs = selectedPlugins.includes("route-docs") + ? selectedPlugins + : [...selectedPlugins, "route-docs" as PluginKey]; + return withRouteDocs.flatMap((pluginKey) => PLUGIN_ROUTES[pluginKey] ?? []); +} diff --git a/playground/src/components/plugin-selector.tsx b/playground/src/components/plugin-selector.tsx new file mode 100644 index 00000000..cc2b6cba --- /dev/null +++ b/playground/src/components/plugin-selector.tsx @@ -0,0 +1,113 @@ +"use client"; + +import type { PluginMeta } from "@btst/codegen/lib"; +import type { PluginKey } from "@btst/codegen/lib"; + +interface PluginSelectorProps { + plugins: readonly PluginMeta[]; + selected: PluginKey[]; + onChange: (plugins: PluginKey[]) => void; + disabled?: boolean; +} + +const PLUGIN_DESCRIPTIONS: Record = { + blog: "Full-featured blog with posts, drafts, and tags", + "ai-chat": "AI chat interface with conversation history", + cms: "Content management system with custom content types", + "form-builder": "Drag-and-drop form builder with submissions", + "ui-builder": "Visual page builder (requires CMS)", + kanban: "Kanban board with drag-and-drop columns and cards", + comments: "Nested comments with moderation dashboard", + media: "Media library with file upload and management", + "route-docs": "Auto-generated route documentation page", + "open-api": "OpenAPI spec endpoint at /api/data/reference", + "better-auth-ui": "Authentication UI (sign in, account, org)", +}; + +export function PluginSelector({ + plugins, + selected, + onChange, + disabled, +}: PluginSelectorProps) { + function toggle(key: PluginKey) { + if (selected.includes(key)) { + onChange(selected.filter((k) => k !== key)); + } else { + onChange([...selected, key]); + } + } + + return ( +
+ {plugins.map((plugin) => { + const isRouteDocs = plugin.key === "route-docs"; + const isSelected = + isRouteDocs || selected.includes(plugin.key as PluginKey); + return ( + + ); + })} +
+ ); +} diff --git a/playground/src/components/route-list.tsx b/playground/src/components/route-list.tsx new file mode 100644 index 00000000..92842135 --- /dev/null +++ b/playground/src/components/route-list.tsx @@ -0,0 +1,101 @@ +"use client"; + +interface RouteListProps { + routes: string[]; + onPageRouteClick?: (route: string) => void; + activePageRoute?: string | null; +} + +const ROUTE_ICONS: Record = { + "/pages/blog": "📝", + "/pages/chat": "💬", + "/pages/cms": "📁", + "/pages/forms": "📋", + "/pages/kanban": "🗂️", + "/pages/media": "🖼️", + "/pages/comments": "💬", + "/pages/route-docs": "📚", + "/pages/ui-builder": "🎨", + "/api/data/reference": "🔌", +}; + +function getIcon(route: string): string { + for (const [prefix, icon] of Object.entries(ROUTE_ICONS)) { + if (route.startsWith(prefix)) return icon; + } + if (route.startsWith("/api/")) return "🔌"; + return "📄"; +} + +function isApiRoute(route: string): boolean { + return route.startsWith("/api/"); +} + +export function RouteList({ + routes, + onPageRouteClick, + activePageRoute, +}: RouteListProps) { + if (routes.length === 0) { + return ( +

+ Select plugins above to see available routes. +

+ ); + } + + const pageRoutes = routes.filter((r) => !isApiRoute(r)); + const apiRoutes = routes.filter((r) => isApiRoute(r)); + + return ( +
+ {pageRoutes.length > 0 && ( +
+

+ Page Routes +

+
    + {pageRoutes.map((route) => ( +
  • + {getIcon(route)} + {onPageRouteClick ? ( + + ) : ( + + {route} + + )} +
  • + ))} +
+
+ )} + {apiRoutes.length > 0 && ( +
+

+ API Routes +

+
    + {apiRoutes.map((route) => ( +
  • + {getIcon(route)} + + {route} + +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/playground/src/components/stackblitz-embed.tsx b/playground/src/components/stackblitz-embed.tsx new file mode 100644 index 00000000..f770b994 --- /dev/null +++ b/playground/src/components/stackblitz-embed.tsx @@ -0,0 +1,238 @@ +"use client"; + +import { useEffect, useRef, useCallback } from "react"; +import type { EmbedOptions, Project } from "@stackblitz/sdk"; +import type { FileWritePlanItem } from "@btst/codegen/lib"; +import { buildProjectFiles, toSdkFiles } from "@/lib/stackblitz-template"; + +interface StackBlitzEmbedProps { + generatedFiles: FileWritePlanItem[]; + cssImports: string[]; + extraPackages: string[]; + previewPath?: string | null; +} + +const EMBED_HEIGHT = 700; + +export function StackBlitzEmbed({ + generatedFiles, + cssImports, + extraPackages, + previewPath, +}: StackBlitzEmbedProps) { + const containerRef = useRef(null); + const projectRef = useRef(null); + const vmRef = useRef(null); + const previewPathRef = useRef(previewPath); + + useEffect(() => { + previewPathRef.current = previewPath; + }, [previewPath]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + let cancelled = false; + + const project: Project = { + title: "BTST Playground Demo", + description: "Generated by BTST Playground", + template: "node", + files: toSdkFiles( + buildProjectFiles(generatedFiles, cssImports, extraPackages), + ), + }; + + projectRef.current = project; + + const embedOptions: EmbedOptions = { + height: EMBED_HEIGHT, + openFile: "app/page.tsx", + terminalHeight: 30, + theme: "dark", + showSidebar: false, + view: "default", + }; + + const log = (...args: unknown[]) => + console.log(`[StackBlitz ${new Date().toISOString()}]`, ...args); + + log("Starting embed — loading SDK..."); + + void import("@stackblitz/sdk") + .then((mod) => { + if (cancelled || !containerRef.current) return; + + container.innerHTML = ""; + + // Create a fresh target for the SDK to replace with its iframe. + // Never pass containerRef.current itself — the SDK replaces the target + // element in the DOM, which would break React's reconciliation. + const target = document.createElement("div"); + container.appendChild(target); + + log("SDK loaded — calling embedProject", { + template: project.template, + fileCount: Object.keys(project.files ?? {}).length, + embedOptions, + }); + + mod.default + .embedProject(target, project, embedOptions) + .then((vm) => { + if (cancelled) return; + vmRef.current = vm; + log( + "VM connected ✓ — WebContainers booting, starting diagnostics...", + ); + if (previewPathRef.current) { + vm.preview + .setUrl(previewPathRef.current) + .then(() => + log( + "Applied initial preview path request:", + previewPathRef.current, + ), + ) + .catch((e) => log("Initial preview path set failed:", e)); + } + + // Snapshot deps to confirm package.json was parsed correctly. + vm.getDependencies() + .then((deps) => log("Resolved dependencies:", deps)) + .catch((e) => log("getDependencies failed:", e)); + + // Poll preview URL until the dev server is up (or we give up). + // NOTE: SDK_GET_PREVIEW_URL_FAILURE is thrown when COEP prevents the + // SDK from reading the cross-origin preview iframe URL. This does NOT + // mean the VM died — continue polling through it. + let attempts = 0; + const maxAttempts = 60; // 5 min — Next.js install takes a while + const pollInterval = setInterval(async () => { + if (cancelled) { + clearInterval(pollInterval); + return; + } + attempts++; + try { + const url = await vm.preview.getUrl(); + log( + `Preview poll #${attempts}: url=${url ?? "null (server not ready yet)"}`, + ); + if (url) { + clearInterval(pollInterval); + log("Dev server is up ✓", { url }); + } else if (attempts >= maxAttempts) { + clearInterval(pollInterval); + log( + `Dev server never came up after ${maxAttempts} polls — likely crashed.`, + ); + vm.getFsSnapshot() + .then((files) => { + const paths = Object.keys(files ?? {}); + log("FS snapshot on crash:", { + totalFiles: paths.length, + hasNodeModules: paths.some((p) => + p.startsWith("node_modules/"), + ), + hasNextDir: paths.some((p) => p.startsWith(".next/")), + samplePaths: paths.slice(0, 20), + }); + }) + .catch((e) => log("getFsSnapshot failed:", e)); + } + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + if (msg.includes("SDK_GET_PREVIEW_URL_FAILURE")) { + // COEP restricts reading cross-origin preview URL — VM is alive. + log( + `Preview poll #${attempts}: COEP blocks preview URL read (VM still alive)`, + ); + } else { + clearInterval(pollInterval); + log( + "Preview poll threw unexpected error — VM may have died:", + e, + ); + } + } + }, 5_000); + }) + .catch((err: unknown) => { + if (cancelled) return; + // VM timeout during boot is common — the iframe still renders. + // Log it anyway so we can distinguish timeout from a real error. + log("embedProject rejected (VM timeout or boot failure):", err); + }); + }) + .catch((err: unknown) => { + if (cancelled) return; + log("Failed to load StackBlitz SDK:", err); + }); + + return () => { + cancelled = true; + vmRef.current = null; + container.innerHTML = ""; + log("Embed unmounted / deps changed — cleaned up."); + }; + }, [generatedFiles, cssImports, extraPackages]); + + useEffect(() => { + if (!previewPath || !vmRef.current) return; + const vm = vmRef.current; + vm.preview + .setUrl(previewPath) + .catch((e) => + console.log( + `[StackBlitz ${new Date().toISOString()}] Failed to set preview URL`, + { previewPath, error: e }, + ), + ); + }, [previewPath]); + + const handleOpenInStackBlitz = useCallback(() => { + if (!projectRef.current) return; + void import("@stackblitz/sdk") + .then((mod) => { + mod.default.openProject(projectRef.current!, { + openFile: "app/page.tsx", + }); + }) + .catch((err: unknown) => { + console.error( + `[StackBlitz ${new Date().toISOString()}] Failed to load SDK`, + err, + ); + }); + }, []); + + return ( +
+
+ +
+
+
+ ); +} diff --git a/playground/src/lib/stackblitz-template.ts b/playground/src/lib/stackblitz-template.ts new file mode 100644 index 00000000..905e9183 --- /dev/null +++ b/playground/src/lib/stackblitz-template.ts @@ -0,0 +1,267 @@ +import type { FileWritePlanItem } from "@btst/codegen/lib"; + +export interface ProjectFile { + content: string; +} + +export type ProjectFiles = Record; + +/** + * Build the complete StackBlitz project file tree. + * + * The base skeleton is a minimal Next.js app wired with the memory adapter. + * The generated files from buildScaffoldPlan are merged on top (overwriting + * any skeleton file with the same path). + */ +export function buildProjectFiles( + generatedFiles: FileWritePlanItem[], + cssImports: string[], + extraPackages: string[] = [], +): ProjectFiles { + const cssImportLines = cssImports.map((c) => `@import "${c}";`).join("\n"); + const baseDependencies: Record = { + "@btst/stack": "latest", + "@btst/adapter-memory": "latest", + "@tanstack/react-query": "^5.0.0", + next: "15.3.4", + react: "19.2.4", + "react-dom": "19.2.4", + }; + const pluginDependencies = Object.fromEntries( + Array.from(new Set(extraPackages)).map((pkgName) => [pkgName, "latest"]), + ); + const dependencies = Object.fromEntries( + Object.entries({ + ...baseDependencies, + ...pluginDependencies, + }).sort(([left], [right]) => left.localeCompare(right)), + ); + + const files: ProjectFiles = { + // ── package.json ──────────────────────────────────────────────────────── + "package.json": { + content: JSON.stringify( + { + name: "btst-playground-demo", + version: "0.0.0", + private: true, + scripts: { + // copy-stack-src.mjs must run before next dev/build so Tailwind's + // WASM oxide scanner can find @btst/stack source outside node_modules. + // See: https://github.com/tailwindlabs/tailwindcss/issues/18418 + dev: "node copy-stack-src.mjs && next dev", + build: "node copy-stack-src.mjs && next build", + start: "next start", + }, + dependencies, + devDependencies: { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + postcss: "^8", + tailwindcss: "^4", + typescript: "^5", + }, + stackblitz: { + installDependencies: false, + startCommand: "pnpm install && pnpm dev", + }, + }, + null, + 2, + ), + }, + + // ── copy-stack-src.mjs ─────────────────────────────────────────────────── + // Tailwind's WASM oxide scanner cannot traverse node_modules inside + // WebContainers (https://github.com/tailwindlabs/tailwindcss/issues/18418). + // This script copies @btst/stack/src outside node_modules so Tailwind + // can scan it. Mirrors the same script used in the demo projects. + "copy-stack-src.mjs": { + content: `#!/usr/bin/env node +import { cp, mkdir, rm } from "fs/promises"; +import { existsSync } from "fs"; + +const src = "node_modules/@btst/stack/src"; +const dest = "app/.btst-stack-src"; +const uiSrc = "node_modules/@btst/stack/dist/packages/ui"; +const uiDest = "app/.btst-stack-ui"; + +if (!existsSync(src)) { + console.log("[copy-stack-src] node_modules/@btst/stack/src not found, skipping"); + process.exit(0); +} + +await rm(dest, { recursive: true, force: true }); +await mkdir(dest, { recursive: true }); +await cp(src, dest, { recursive: true }); +console.log(\`[copy-stack-src] copied \${src} → \${dest}\`); + +if (existsSync(uiSrc)) { + await rm(uiDest, { recursive: true, force: true }); + await mkdir(uiDest, { recursive: true }); + await cp(uiSrc, uiDest, { recursive: true }); + console.log(\`[copy-stack-src] copied \${uiSrc} → \${uiDest}\`); +} else { + console.log(\`[copy-stack-src] \${uiSrc} not found, skipping\`); +} + +// When running inside the monorepo, workspace-built dist/plugins/ has +// @workspace/ui imports already inlined by postbuild.cjs. Overlay these +// files onto the npm-installed ones so plugin CSS stays self-contained. +// In StackBlitz/WebContainers this path won't exist, so this is a no-op. +const workspacePluginsDist = "../../packages/stack/dist/plugins"; +const npmPluginsDist = "node_modules/@btst/stack/dist/plugins"; +if (existsSync(workspacePluginsDist)) { + await cp(workspacePluginsDist, npmPluginsDist, { recursive: true }); + console.log( + \`[copy-stack-src] overlaid \${workspacePluginsDist} → \${npmPluginsDist}\`, + ); +} +`, + }, + + // ── .npmrc ────────────────────────────────────────────────────────────── + // Needed in StackBlitz WebContainers: prevents native module build + // failures and engine version mismatch errors during npm install. + ".npmrc": { + content: `legacy-peer-deps=true\nengine-strict=false\n`, + }, + + // ── next.config.ts ─────────────────────────────────────────────────────── + "next.config.ts": { + content: `import type { NextConfig } from "next" + +const config: NextConfig = { + reactStrictMode: true, +} + +export default config +`, + }, + + // ── tsconfig.json ──────────────────────────────────────────────────────── + "tsconfig.json": { + content: JSON.stringify( + { + compilerOptions: { + baseUrl: ".", + target: "ESNext", + lib: ["dom", "dom.iterable", "esnext"], + allowJs: true, + skipLibCheck: true, + strict: true, + forceConsistentCasingInFileNames: true, + noEmit: true, + esModuleInterop: true, + module: "esnext", + moduleResolution: "bundler", + resolveJsonModule: true, + isolatedModules: true, + jsx: "preserve", + incremental: true, + paths: { "@/*": ["./*"] }, + plugins: [{ name: "next" }], + }, + include: [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ], + exclude: ["node_modules"], + }, + null, + 2, + ), + }, + + // ── postcss.config.mjs ────────────────────────────────────────────────── + "postcss.config.mjs": { + content: `export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +} +`, + }, + + // ── app/globals.css ────────────────────────────────────────────────────── + "app/globals.css": { + content: `@import "tailwindcss"; +${cssImportLines ? `\n${cssImportLines}\n` : ""} +/* WebContainers: Tailwind's WASM scanner can't traverse node_modules */ +/* (https://github.com/tailwindlabs/tailwindcss/issues/18418). */ +/* copy-stack-src.mjs copies @btst/stack source here before dev/build runs. */ +@source "./.btst-stack-src/**/*.{ts,tsx}"; +@source "./.btst-stack-ui/**/*.{ts,tsx}"; +`, + }, + + // ── app/layout.tsx ─────────────────────────────────────────────────────── + "app/layout.tsx": { + content: `import "./globals.css" +import type { ReactNode } from "react" + +export const metadata = { title: "BTST Playground" } + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} +`, + }, + + // ── app/page.tsx ───────────────────────────────────────────────────────── + "app/page.tsx": { + content: `import Link from "next/link" + +export default function Home() { + return ( +
+

+ BTST Playground +

+

+ A demo project generated by the{" "} + + BTST Playground + + . Navigate to a plugin route below to see it in action. +

+ +
+ ) +} +`, + }, + }; + + // Merge generated files on top of the skeleton + for (const file of generatedFiles) { + files[file.path] = { content: file.content }; + } + + return files; +} + +/** + * Convert project files to the format expected by @stackblitz/sdk embedProject() + */ +export function toSdkFiles(projectFiles: ProjectFiles): Record { + return Object.fromEntries( + Object.entries(projectFiles).map(([path, { content }]) => [path, content]), + ); +} diff --git a/playground/tsconfig.json b/playground/tsconfig.json new file mode 100644 index 00000000..12d07021 --- /dev/null +++ b/playground/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "target": "ESNext", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "paths": { + "@/*": ["./src/*"] + }, + "plugins": [{ "name": "next" }] + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": ["node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0fe92229..d05085d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -993,6 +993,46 @@ importers: specifier: 'catalog:' version: 5.9.3 + playground: + dependencies: + '@btst/codegen': + specifier: workspace:* + version: link:../packages/cli + '@stackblitz/sdk': + specifier: ^1.9.0 + version: 1.11.0 + next: + specifier: 16.0.10 + version: 16.0.10(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: + specifier: ^19.2.0 + version: 19.2.0 + react-dom: + specifier: ^19.2.0 + version: 19.2.0(react@19.2.0) + devDependencies: + '@tailwindcss/postcss': + specifier: ^4.1.10 + version: 4.1.17 + '@types/node': + specifier: ^24.0.0 + version: 24.10.1 + '@types/react': + specifier: ^19.2.2 + version: 19.2.6 + '@types/react-dom': + specifier: ^19.2.2 + version: 19.2.3(@types/react@19.2.6) + postcss: + specifier: ^8.5.6 + version: 8.5.6 + tailwindcss: + specifier: ^4.1.10 + version: 4.1.17 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + packages: '@ai-sdk/gateway@2.0.10': @@ -4207,6 +4247,9 @@ packages: resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} engines: {node: '>=18.0.0'} + '@stackblitz/sdk@1.11.0': + resolution: {integrity: sha512-DFQGANNkEZRzFk1/rDP6TcFdM82ycHE+zfl9C/M/jXlH68jiqHWHFMQURLELoD8koxvu/eW5uhg94NSAZlYrUQ==} + '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} @@ -14616,6 +14659,8 @@ snapshots: dependencies: tslib: 2.8.1 + '@stackblitz/sdk@1.11.0': {} + '@standard-schema/spec@1.0.0': {} '@standard-schema/spec@1.1.0': {} @@ -15498,7 +15543,7 @@ snapshots: '@types/glob@7.2.0': dependencies: '@types/minimatch': 6.0.0 - '@types/node': 20.19.25 + '@types/node': 24.10.1 '@types/hast@3.0.4': dependencies: @@ -15583,7 +15628,7 @@ snapshots: '@types/through@0.0.33': dependencies: - '@types/node': 20.19.25 + '@types/node': 24.10.1 '@types/tinycolor2@1.4.6': {} @@ -16418,7 +16463,7 @@ snapshots: bun-types@1.3.2(@types/react@19.2.6): dependencies: - '@types/node': 20.19.25 + '@types/node': 24.10.1 '@types/react': 19.2.6 bytes@3.1.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 95a67539..00393864 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - packages/** - docs + - playground - examples/* - e2e diff --git a/tsconfig.json b/tsconfig.json index fed1535e..aecc3527 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,5 +15,5 @@ "noErrorTruncation": true, "types": ["node"] }, - "exclude": ["**/dist/**", "**/node_modules/**"] + "exclude": ["**/dist/**", "**/node_modules/**", "playground"] }