From 51751afb760c0be1941483edcc18b6a2e7174a67 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Thu, 26 Mar 2026 17:34:17 -0400 Subject: [PATCH 1/9] feat: playground --- packages/cli/build.config.ts | 2 +- packages/cli/package.json | 10 + packages/cli/src/commands/init.ts | 11 +- packages/cli/src/lib.ts | 15 + packages/cli/src/utils/constants.ts | 42 +++ packages/cli/src/utils/plugin-routes.ts | 5 + packages/cli/src/utils/render-template.ts | 3 + .../src/plugins/route-docs/client/index.ts | 3 + .../src/plugins/route-docs/client/plugin.tsx | 57 ++++ playground/next-env.d.ts | 6 + playground/next.config.mjs | 9 + playground/package.json | 27 ++ playground/postcss.config.mjs | 5 + playground/src/app/actions.ts | 47 +++ playground/src/app/globals.css | 1 + playground/src/app/layout.tsx | 22 ++ playground/src/app/page.tsx | 63 ++++ .../src/components/playground-client.tsx | 291 ++++++++++++++++++ playground/src/components/plugin-selector.tsx | 110 +++++++ playground/src/components/route-list.tsx | 82 +++++ .../src/components/stackblitz-embed.tsx | 138 +++++++++ playground/src/lib/stackblitz-template.ts | 187 +++++++++++ playground/tsconfig.json | 31 ++ pnpm-lock.yaml | 51 ++- pnpm-workspace.yaml | 1 + tsconfig.json | 2 +- 26 files changed, 1215 insertions(+), 6 deletions(-) create mode 100644 packages/cli/src/lib.ts create mode 100644 packages/cli/src/utils/plugin-routes.ts create mode 100644 playground/next-env.d.ts create mode 100644 playground/next.config.mjs create mode 100644 playground/package.json create mode 100644 playground/postcss.config.mjs create mode 100644 playground/src/app/actions.ts create mode 100644 playground/src/app/globals.css create mode 100644 playground/src/app/layout.tsx create mode 100644 playground/src/app/page.tsx create mode 100644 playground/src/components/playground-client.tsx create mode 100644 playground/src/components/plugin-selector.tsx create mode 100644 playground/src/components/route-list.tsx create mode 100644 playground/src/components/stackblitz-embed.tsx create mode 100644 playground/src/lib/stackblitz-template.ts create mode 100644 playground/tsconfig.json 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/index.ts b/packages/stack/src/plugins/route-docs/client/index.ts index a513a656..e2647a97 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, + useRegisteredRoutes, + type RegisteredRoute, } from "./plugin"; diff --git a/packages/stack/src/plugins/route-docs/client/plugin.tsx b/packages/stack/src/plugins/route-docs/client/plugin.tsx index 178c6f92..1eabb6d1 100644 --- a/packages/stack/src/plugins/route-docs/client/plugin.tsx +++ b/packages/stack/src/plugins/route-docs/client/plugin.tsx @@ -42,6 +42,63 @@ 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; +} + +/** + * 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; +} + /** * 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..874b24fd --- /dev/null +++ b/playground/next.config.mjs @@ -0,0 +1,9 @@ +/** @type {import('next').NextConfig} */ +const config = { + reactStrictMode: true, + basePath: "/playground", + assetPrefix: "/playground", + serverExternalPackages: ["handlebars"], +}; + +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..d0a42cd5 --- /dev/null +++ b/playground/src/app/actions.ts @@ -0,0 +1,47 @@ +"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[]; +} + +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] ?? []); + + return { + files: plan.files, + routes, + cssImports, + }; +} 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..e8984166 --- /dev/null +++ b/playground/src/components/playground-client.tsx @@ -0,0 +1,291 @@ +"use client"; + +import { useState, useTransition, useCallback } from "react"; +import type { + PluginMeta, + PluginKey, + FileWritePlanItem, +} 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[]; +} + +export function PlaygroundClient({ plugins }: PlaygroundClientProps) { + const [selected, setSelected] = useState(["blog"]); + const [view, setView] = useState("configure"); + const [generated, setGenerated] = useState(null); + const [isPending, startTransition] = useTransition(); + + const handleLaunch = useCallback(() => { + startTransition(async () => { + const result = await generateProject(selected); + setGenerated(result); + setView("preview"); + }); + }, [selected]); + + const handleBack = useCallback(() => { + setView("configure"); + }, []); + + // Preview routes are either from the generated state or derived from selection + const previewRoutes = generated?.routes ?? []; + + 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

+

+ {selected.length} 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[], + plugins: readonly PluginMeta[], +): string[] { + // Inline the route map here so it works client-side without the server action + const 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": ["/api/data/reference"], + "better-auth-ui": ["/pages/auth", "/pages/account/settings", "/pages/org"], + }; + + const withRouteDocs = selected.includes("route-docs") + ? selected + : [...selected, "route-docs" as PluginKey]; + return withRouteDocs.flatMap((p) => ROUTES[p] ?? []); +} diff --git a/playground/src/components/plugin-selector.tsx b/playground/src/components/plugin-selector.tsx new file mode 100644 index 00000000..e9aa9cf0 --- /dev/null +++ b/playground/src/components/plugin-selector.tsx @@ -0,0 +1,110 @@ +"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 isSelected = selected.includes(plugin.key as PluginKey); + const isRouteDocs = plugin.key === "route-docs"; + return ( + + ); + })} +
+ ); +} diff --git a/playground/src/components/route-list.tsx b/playground/src/components/route-list.tsx new file mode 100644 index 00000000..a5ae74b1 --- /dev/null +++ b/playground/src/components/route-list.tsx @@ -0,0 +1,82 @@ +"use client"; + +interface RouteListProps { + routes: string[]; +} + +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 }: 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)} + + {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..0422873d --- /dev/null +++ b/playground/src/components/stackblitz-embed.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import type { FileWritePlanItem } from "@btst/codegen/lib"; +import { buildProjectFiles, toSdkFiles } from "@/lib/stackblitz-template"; + +interface StackBlitzEmbedProps { + generatedFiles: FileWritePlanItem[]; + cssImports: string[]; +} + +export function StackBlitzEmbed({ + generatedFiles, + cssImports, +}: StackBlitzEmbedProps) { + const containerRef = useRef(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const vmRef = useRef(null); + + useEffect(() => { + if (!containerRef.current) return; + + let cancelled = false; + + async function embed() { + setLoading(true); + setError(null); + + try { + // Dynamically import StackBlitz SDK (client-only) + const sdk = await import("@stackblitz/sdk"); + + if (cancelled || !containerRef.current) return; + + const projectFiles = buildProjectFiles(generatedFiles, cssImports); + const sdkFiles = toSdkFiles(projectFiles); + + // Destroy previous VM if any + if (vmRef.current) { + try { + vmRef.current = null; + } catch {} + } + + // Clear container + containerRef.current.innerHTML = ""; + + const vm = await sdk.default.embedProject( + containerRef.current, + { + title: "BTST Playground Demo", + description: "Generated by BTST Playground", + template: "node", + files: sdkFiles, + }, + { + height: "100%", + hideNavigation: false, + openFile: "app/page.tsx", + terminalHeight: 30, + startScript: "dev", + theme: "dark", + }, + ); + + if (!cancelled) { + vmRef.current = vm; + setLoading(false); + } + } catch (err) { + if (!cancelled) { + setError( + err instanceof Error + ? err.message + : "Failed to load StackBlitz embed", + ); + setLoading(false); + } + } + } + + embed(); + + return () => { + cancelled = true; + }; + }, [generatedFiles, cssImports]); + + return ( +
+ {loading && ( +
+ + + + +

Starting WebContainer…

+

+ This may take 30–60 seconds on first load +

+
+ )} + {error && ( +
+

+ Failed to load StackBlitz +

+

{error}

+

+ Make sure you have cross-origin isolation headers enabled, or try + opening in Chrome. +

+
+ )} +
+
+ ); +} diff --git a/playground/src/lib/stackblitz-template.ts b/playground/src/lib/stackblitz-template.ts new file mode 100644 index 00000000..b021906c --- /dev/null +++ b/playground/src/lib/stackblitz-template.ts @@ -0,0 +1,187 @@ +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[], +): ProjectFiles { + const cssImportLines = cssImports.map((c) => `@import "${c}";`).join("\n"); + + const files: ProjectFiles = { + // ── package.json ──────────────────────────────────────────────────────── + "package.json": { + content: JSON.stringify( + { + name: "btst-playground-demo", + version: "0.0.0", + private: true, + scripts: { + dev: "next dev", + build: "next build", + start: "next start", + }, + dependencies: { + "@btst/stack": "latest", + "@btst/adapter-memory": "latest", + "@tanstack/react-query": "^5.0.0", + next: "15.3.4", + react: "^19.0.0", + "react-dom": "^19.0.0", + }, + devDependencies: { + "@tailwindcss/postcss": "^4.1.10", + "@types/node": "^24.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + postcss: "^8.5.0", + tailwindcss: "^4.1.10", + typescript: "^5.8.0", + }, + }, + null, + 2, + ), + }, + + // ── 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` : ""}`, + }, + + // ── 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"] } From acfd7a83dac8001bb4e8923aa55adea1b43a5e77 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Thu, 26 Mar 2026 20:04:48 -0400 Subject: [PATCH 2/9] refactor: improve StackBlitz embed functionality and project file handling, including new copy script for Tailwind compatibility --- .../src/components/stackblitz-embed.tsx | 250 +++++++++++------- playground/src/lib/stackblitz-template.ts | 78 +++++- 2 files changed, 216 insertions(+), 112 deletions(-) diff --git a/playground/src/components/stackblitz-embed.tsx b/playground/src/components/stackblitz-embed.tsx index 0422873d..bc441228 100644 --- a/playground/src/components/stackblitz-embed.tsx +++ b/playground/src/components/stackblitz-embed.tsx @@ -1,6 +1,7 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +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"; @@ -9,129 +10,178 @@ interface StackBlitzEmbedProps { cssImports: string[]; } +const EMBED_HEIGHT = 700; + export function StackBlitzEmbed({ generatedFiles, cssImports, }: StackBlitzEmbedProps) { const containerRef = useRef(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const vmRef = useRef(null); + const projectRef = useRef(null); useEffect(() => { - if (!containerRef.current) return; + const container = containerRef.current; + if (!container) return; let cancelled = false; - async function embed() { - setLoading(true); - setError(null); - - try { - // Dynamically import StackBlitz SDK (client-only) - const sdk = await import("@stackblitz/sdk"); - - if (cancelled || !containerRef.current) return; - - const projectFiles = buildProjectFiles(generatedFiles, cssImports); - const sdkFiles = toSdkFiles(projectFiles); - - // Destroy previous VM if any - if (vmRef.current) { - try { - vmRef.current = null; - } catch {} - } - - // Clear container - containerRef.current.innerHTML = ""; - - const vm = await sdk.default.embedProject( - containerRef.current, - { - title: "BTST Playground Demo", - description: "Generated by BTST Playground", - template: "node", - files: sdkFiles, - }, - { - height: "100%", - hideNavigation: false, - openFile: "app/page.tsx", - terminalHeight: 30, - startScript: "dev", - theme: "dark", - }, - ); - - if (!cancelled) { - vmRef.current = vm; - setLoading(false); - } - } catch (err) { - if (!cancelled) { - setError( - err instanceof Error - ? err.message - : "Failed to load StackBlitz embed", + const project: Project = { + title: "BTST Playground Demo", + description: "Generated by BTST Playground", + template: "node", + files: toSdkFiles(buildProjectFiles(generatedFiles, cssImports)), + }; + + 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..."); + + 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; + log( + "VM connected ✓ — WebContainers booting, starting diagnostics...", ); - setLoading(false); - } - } - } - embed(); + // 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); + }); + }); return () => { cancelled = true; + container.innerHTML = ""; + log("Embed unmounted / deps changed — cleaned up."); }; }, [generatedFiles, cssImports]); + const handleOpenInStackBlitz = useCallback(() => { + if (!projectRef.current) return; + import("@stackblitz/sdk").then((mod) => { + mod.default.openProject(projectRef.current!, { + openFile: "app/page.tsx", + }); + }); + }, []); + return ( -
- {loading && ( -
+
+
+
- )} - {error && ( -
-

- Failed to load StackBlitz -

-

{error}

-

- Make sure you have cross-origin isolation headers enabled, or try - opening in Chrome. -

-
- )} + Open in StackBlitz + +
); diff --git a/playground/src/lib/stackblitz-template.ts b/playground/src/lib/stackblitz-template.ts index b021906c..5e2d724c 100644 --- a/playground/src/lib/stackblitz-template.ts +++ b/playground/src/lib/stackblitz-template.ts @@ -28,8 +28,11 @@ export function buildProjectFiles( version: "0.0.0", private: true, scripts: { - dev: "next dev", - build: "next build", + // 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: { @@ -37,17 +40,21 @@ export function buildProjectFiles( "@btst/adapter-memory": "latest", "@tanstack/react-query": "^5.0.0", next: "15.3.4", - react: "^19.0.0", - "react-dom": "^19.0.0", + react: "19.2.4", + "react-dom": "19.2.4", }, devDependencies: { - "@tailwindcss/postcss": "^4.1.10", - "@types/node": "^24.0.0", - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0", - postcss: "^8.5.0", - tailwindcss: "^4.1.10", - typescript: "^5.8.0", + "@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, @@ -55,6 +62,47 @@ export function buildProjectFiles( ), }, + // ── 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}\`); +} +`, + }, + + // ── .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" @@ -116,7 +164,13 @@ export default config // ── app/globals.css ────────────────────────────────────────────────────── "app/globals.css": { content: `@import "tailwindcss"; -${cssImportLines ? `\n${cssImportLines}\n` : ""}`, +${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 ─────────────────────────────────────────────────────── From e355eb7b4a211c4a14319c40f362493e21d11f9a Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Thu, 26 Mar 2026 20:04:59 -0400 Subject: [PATCH 3/9] feat: add custom headers for cross-origin isolation in Next.js configuration --- playground/next.config.mjs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/playground/next.config.mjs b/playground/next.config.mjs index 874b24fd..f24f265c 100644 --- a/playground/next.config.mjs +++ b/playground/next.config.mjs @@ -4,6 +4,20 @@ const config = { 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; From 28f0127cd77eb3f60e60b147fe5dc444a2ad9e2c Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Thu, 26 Mar 2026 20:18:56 -0400 Subject: [PATCH 4/9] feat: enhance stackblitz-template to handle workspace-built plugins and improve error logging for missing source files --- playground/src/lib/stackblitz-template.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/playground/src/lib/stackblitz-template.ts b/playground/src/lib/stackblitz-template.ts index 5e2d724c..b07cd5cd 100644 --- a/playground/src/lib/stackblitz-template.ts +++ b/playground/src/lib/stackblitz-template.ts @@ -92,6 +92,21 @@ if (existsSync(uiSrc)) { 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}\`, + ); } `, }, From ba90e8f19e90722811278aab634e36778f3e73d6 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Thu, 26 Mar 2026 20:32:21 -0400 Subject: [PATCH 5/9] feat: add active route preview functionality in PlaygroundClient and enhance RouteList component for route selection --- .../src/plugins/route-docs/client/plugin.tsx | 2 +- .../src/components/playground-client.tsx | 19 ++++++++++- playground/src/components/route-list.tsx | 27 ++++++++++++--- .../src/components/stackblitz-embed.tsx | 34 +++++++++++++++++++ 4 files changed, 76 insertions(+), 6 deletions(-) diff --git a/packages/stack/src/plugins/route-docs/client/plugin.tsx b/packages/stack/src/plugins/route-docs/client/plugin.tsx index 1eabb6d1..8ae76cfa 100644 --- a/packages/stack/src/plugins/route-docs/client/plugin.tsx +++ b/packages/stack/src/plugins/route-docs/client/plugin.tsx @@ -1,4 +1,4 @@ -import { lazy } from "react"; +import { lazy, useEffect, useState } from "react"; import { defineClientPlugin } from "@btst/stack/plugins/client"; import { createRoute } from "@btst/yar"; import type { QueryClient } from "@tanstack/react-query"; diff --git a/playground/src/components/playground-client.tsx b/playground/src/components/playground-client.tsx index e8984166..5631afcf 100644 --- a/playground/src/components/playground-client.tsx +++ b/playground/src/components/playground-client.tsx @@ -27,22 +27,34 @@ 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 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 (
@@ -216,6 +228,7 @@ export function PlaygroundClient({ plugins }: PlaygroundClientProps) { )}
@@ -223,7 +236,11 @@ export function PlaygroundClient({ plugins }: PlaygroundClientProps) { {/* Route list sidebar */}

Available routes

- +

Navigate inside the preview to: diff --git a/playground/src/components/route-list.tsx b/playground/src/components/route-list.tsx index a5ae74b1..92842135 100644 --- a/playground/src/components/route-list.tsx +++ b/playground/src/components/route-list.tsx @@ -2,6 +2,8 @@ interface RouteListProps { routes: string[]; + onPageRouteClick?: (route: string) => void; + activePageRoute?: string | null; } const ROUTE_ICONS: Record = { @@ -29,7 +31,11 @@ function isApiRoute(route: string): boolean { return route.startsWith("/api/"); } -export function RouteList({ routes }: RouteListProps) { +export function RouteList({ + routes, + onPageRouteClick, + activePageRoute, +}: RouteListProps) { if (routes.length === 0) { return (

@@ -52,9 +58,22 @@ export function RouteList({ routes }: RouteListProps) { {pageRoutes.map((route) => (

  • {getIcon(route)} - - {route} - + {onPageRouteClick ? ( + + ) : ( + + {route} + + )}
  • ))} diff --git a/playground/src/components/stackblitz-embed.tsx b/playground/src/components/stackblitz-embed.tsx index bc441228..39e9cc24 100644 --- a/playground/src/components/stackblitz-embed.tsx +++ b/playground/src/components/stackblitz-embed.tsx @@ -8,6 +8,7 @@ import { buildProjectFiles, toSdkFiles } from "@/lib/stackblitz-template"; interface StackBlitzEmbedProps { generatedFiles: FileWritePlanItem[]; cssImports: string[]; + previewPath?: string | null; } const EMBED_HEIGHT = 700; @@ -15,9 +16,16 @@ const EMBED_HEIGHT = 700; export function StackBlitzEmbed({ generatedFiles, cssImports, + 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; @@ -69,9 +77,21 @@ export function StackBlitzEmbed({ .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() @@ -144,11 +164,25 @@ export function StackBlitzEmbed({ return () => { cancelled = true; + vmRef.current = null; container.innerHTML = ""; log("Embed unmounted / deps changed — cleaned up."); }; }, [generatedFiles, cssImports]); + 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; import("@stackblitz/sdk").then((mod) => { From de0c24b2d3779c1d0d89e5098f539587ba261a68 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Thu, 26 Mar 2026 20:34:34 -0400 Subject: [PATCH 6/9] refactor: streamline StackBlitz SDK loading and error handling in embed functionality --- .../src/components/stackblitz-embed.tsx | 216 +++++++++--------- 1 file changed, 114 insertions(+), 102 deletions(-) diff --git a/playground/src/components/stackblitz-embed.tsx b/playground/src/components/stackblitz-embed.tsx index 39e9cc24..058300d7 100644 --- a/playground/src/components/stackblitz-embed.tsx +++ b/playground/src/components/stackblitz-embed.tsx @@ -56,111 +56,116 @@ export function StackBlitzEmbed({ log("Starting embed — loading SDK..."); - 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, - }); + 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; + 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)); } - 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) { + + // 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); - 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)); + return; } - } 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); + attempts++; + try { + const url = await vm.preview.getUrl(); log( - "Preview poll threw unexpected error — VM may have died:", - e, + `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); - }); - }); + }, 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; @@ -185,11 +190,18 @@ export function StackBlitzEmbed({ const handleOpenInStackBlitz = useCallback(() => { if (!projectRef.current) return; - import("@stackblitz/sdk").then((mod) => { - mod.default.openProject(projectRef.current!, { - openFile: "app/page.tsx", + 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 ( From 6b5ae389a36958c754bb6af6d8daaefaf9c5141e Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Fri, 27 Mar 2026 12:08:16 -0400 Subject: [PATCH 7/9] feat: implement useRegisteredRoutes hook for retrieving registered client route paths --- .../skills/btst-client-plugin-dev/SKILL.md | 18 +++++++++++++++++ .../src/plugins/route-docs/client/hooks.ts | 20 +++++++++++++++++++ .../src/plugins/route-docs/client/index.ts | 2 +- .../src/plugins/route-docs/client/plugin.tsx | 16 +-------------- 4 files changed, 40 insertions(+), 16 deletions(-) create mode 100644 packages/stack/src/plugins/route-docs/client/hooks.ts 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/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 e2647a97..fbfa7e2b 100644 --- a/packages/stack/src/plugins/route-docs/client/index.ts +++ b/packages/stack/src/plugins/route-docs/client/index.ts @@ -5,6 +5,6 @@ export { generateSchema, getStoredContext, getRegisteredRoutes, - useRegisteredRoutes, 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 8ae76cfa..38a4674c 100644 --- a/packages/stack/src/plugins/route-docs/client/plugin.tsx +++ b/packages/stack/src/plugins/route-docs/client/plugin.tsx @@ -1,4 +1,4 @@ -import { lazy, useEffect, useState } from "react"; +import { lazy } from "react"; import { defineClientPlugin } from "@btst/stack/plugins/client"; import { createRoute } from "@btst/yar"; import type { QueryClient } from "@tanstack/react-query"; @@ -85,20 +85,6 @@ export function getRegisteredRoutes(): RegisteredRoute[] { return result; } -/** - * 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; -} - /** * Generate the route docs schema from the stored context * This can be called from both server and client From 1435d401179dce4211472cd00db6ca2d61c36897 Mon Sep 17 00:00:00 2001 From: olliethedev <3martynov@gmail.com> Date: Fri, 27 Mar 2026 12:08:53 -0400 Subject: [PATCH 8/9] feat: add extraPackages support in project generation and enhance StackBlitz embed functionality --- playground/src/app/actions.ts | 9 ++++ .../src/components/playground-client.tsx | 53 ++++--------------- playground/src/components/plugin-selector.tsx | 9 ++-- .../src/components/stackblitz-embed.tsx | 8 ++- playground/src/lib/stackblitz-template.ts | 27 +++++++--- 5 files changed, 50 insertions(+), 56 deletions(-) diff --git a/playground/src/app/actions.ts b/playground/src/app/actions.ts index d0a42cd5..a10b068c 100644 --- a/playground/src/app/actions.ts +++ b/playground/src/app/actions.ts @@ -7,6 +7,7 @@ export interface GenerateResult { files: FileWritePlanItem[]; routes: string[]; cssImports: string[]; + extraPackages: string[]; } export async function generateProject( @@ -38,10 +39,18 @@ export async function generateProject( .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/components/playground-client.tsx b/playground/src/components/playground-client.tsx index 5631afcf..e085d99a 100644 --- a/playground/src/components/playground-client.tsx +++ b/playground/src/components/playground-client.tsx @@ -6,6 +6,7 @@ import type { 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"; @@ -21,6 +22,7 @@ interface GeneratedState { files: FileWritePlanItem[]; routes: string[]; cssImports: string[]; + extraPackages: string[]; } export function PlaygroundClient({ plugins }: PlaygroundClientProps) { @@ -31,6 +33,9 @@ export function PlaygroundClient({ plugins }: PlaygroundClientProps) { null, ); const [isPending, startTransition] = useTransition(); + const selectedCount = selected.includes("route-docs") + ? selected.length + : selected.length + 1; const handleLaunch = useCallback(() => { startTransition(async () => { @@ -81,7 +86,7 @@ export function PlaygroundClient({ plugins }: PlaygroundClientProps) {

    Select plugins

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

    @@ -122,7 +127,7 @@ export function PlaygroundClient({ plugins }: PlaygroundClientProps) {

    Available routes

    - +
    {/* Launch button */} @@ -228,6 +233,7 @@ export function PlaygroundClient({ plugins }: PlaygroundClientProps) { )} @@ -261,48 +267,9 @@ export function PlaygroundClient({ plugins }: PlaygroundClientProps) { } // Derive routes from the current plugin selection before generating -function getRoutesForSelection( - selected: PluginKey[], - plugins: readonly PluginMeta[], -): string[] { - // Inline the route map here so it works client-side without the server action - const 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": ["/api/data/reference"], - "better-auth-ui": ["/pages/auth", "/pages/account/settings", "/pages/org"], - }; - +function getRoutesForSelection(selected: PluginKey[]): string[] { const withRouteDocs = selected.includes("route-docs") ? selected : [...selected, "route-docs" as PluginKey]; - return withRouteDocs.flatMap((p) => ROUTES[p] ?? []); + return withRouteDocs.flatMap((pluginKey) => PLUGIN_ROUTES[pluginKey] ?? []); } diff --git a/playground/src/components/plugin-selector.tsx b/playground/src/components/plugin-selector.tsx index e9aa9cf0..cc2b6cba 100644 --- a/playground/src/components/plugin-selector.tsx +++ b/playground/src/components/plugin-selector.tsx @@ -41,8 +41,9 @@ export function PluginSelector({ return (
    {plugins.map((plugin) => { - const isSelected = selected.includes(plugin.key as PluginKey); const isRouteDocs = plugin.key === "route-docs"; + const isSelected = + isRouteDocs || selected.includes(plugin.key as PluginKey); return (