diff --git a/.prettierignore b/.prettierignore index ef53c5f9..7ba2b156 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,4 @@ pnpm*.yaml packages/api/drizzle/* +packages/api/apps/templates/**/* **/*.src.md diff --git a/packages/api/.eslintrc.cjs b/packages/api/.eslintrc.cjs index d8465d3c..d15afc5e 100644 --- a/packages/api/.eslintrc.cjs +++ b/packages/api/.eslintrc.cjs @@ -10,4 +10,5 @@ module.exports = { globals: { Bun: false, }, + ignorePatterns: ['apps/templates/**/*'], }; diff --git a/packages/api/apps/app.mts b/packages/api/apps/app.mts new file mode 100644 index 00000000..14889a18 --- /dev/null +++ b/packages/api/apps/app.mts @@ -0,0 +1,71 @@ +import { CodeLanguageType, randomid, type AppType } from '@srcbook/shared'; +import { db } from '../db/index.mjs'; +import { type App as DBAppType, apps as appsTable } from '../db/schema.mjs'; +import { createViteApp, deleteViteApp, pathToApp } from './disk.mjs'; +import { CreateAppSchemaType } from './schemas.mjs'; +import { asc, desc, eq } from 'drizzle-orm'; +import { npmInstall } from '../exec.mjs'; + +function toSecondsSinceEpoch(date: Date): number { + return Math.floor(date.getTime() / 1000); +} + +export function serializeApp(app: DBAppType): AppType { + return { + id: app.externalId, + name: app.name, + language: app.language as CodeLanguageType, + createdAt: toSecondsSinceEpoch(app.createdAt), + updatedAt: toSecondsSinceEpoch(app.updatedAt), + }; +} + +async function insert( + attrs: Pick, +): Promise { + const [app] = await db.insert(appsTable).values(attrs).returning(); + return app!; +} + +export async function createApp(data: CreateAppSchemaType): Promise { + const app = await insert({ + name: data.name, + language: data.language, + externalId: randomid(), + }); + + await createViteApp(app); + + // TODO: handle this better. + // This should be done somewhere else and surface issues or retries. + // Not awaiting here because it's "happening in the background". + npmInstall({ + cwd: pathToApp(app.externalId), + stdout(data) { + console.log(data.toString('utf8')); + }, + stderr(data) { + console.error(data.toString('utf8')); + }, + onExit(code) { + console.log(`npm install exit code: ${code}`); + }, + }); + + return app; +} + +export async function deleteApp(id: string) { + await db.delete(appsTable).where(eq(appsTable.externalId, id)); + await deleteViteApp(id); +} + +export function loadApps(sort: 'asc' | 'desc') { + const sorter = sort === 'asc' ? asc : desc; + return db.select().from(appsTable).orderBy(sorter(appsTable.updatedAt)); +} + +export async function loadApp(id: string) { + const [app] = await db.select().from(appsTable).where(eq(appsTable.externalId, id)); + return app; +} diff --git a/packages/api/apps/disk.mts b/packages/api/apps/disk.mts new file mode 100644 index 00000000..e1d30f32 --- /dev/null +++ b/packages/api/apps/disk.mts @@ -0,0 +1,170 @@ +import fs from 'node:fs/promises'; +import Path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { type App as DBAppType } from '../db/schema.mjs'; +import { APPS_DIR } from '../constants.mjs'; +import { toValidPackageName } from './utils.mjs'; +import { Dirent } from 'node:fs'; +import { FileType } from '@srcbook/shared'; + +export function pathToApp(id: string) { + return Path.join(APPS_DIR, id); +} + +function pathToTemplate(template: string) { + return Path.resolve(fileURLToPath(import.meta.url), '..', 'templates', template); +} + +export function deleteViteApp(id: string) { + return fs.rm(pathToApp(id), { recursive: true }); +} + +export async function createViteApp(app: DBAppType) { + const appPath = pathToApp(app.externalId); + + // Use recursive because its parent directory may not exist. + await fs.mkdir(appPath, { recursive: true }); + + // Scaffold all the necessary project files. + await scaffold(app, appPath); + + return app; +} + +async function scaffold(app: DBAppType, destDir: string) { + const template = `react-${app.language}`; + + function write(file: string, content?: string) { + const targetPath = Path.join(destDir, file); + return content === undefined + ? copy(Path.join(templateDir, file), targetPath) + : fs.writeFile(targetPath, content, 'utf-8'); + } + + const templateDir = pathToTemplate(template); + const files = await fs.readdir(templateDir); + for (const file of files.filter((f) => f !== 'package.json')) { + await write(file); + } + + const [pkgContents, idxContents] = await Promise.all([ + fs.readFile(Path.join(templateDir, 'package.json'), 'utf-8'), + fs.readFile(Path.join(templateDir, 'index.html'), 'utf-8'), + ]); + + const pkg = JSON.parse(pkgContents); + pkg.name = toValidPackageName(app.name); + const updatedPkgContents = JSON.stringify(pkg, null, 2) + '\n'; + + const updatedIdxContents = idxContents.replace( + /.*<\/title>/, + `<title>${app.name}`, + ); + + await Promise.all([ + write('package.json', updatedPkgContents), + write('index.html', updatedIdxContents), + ]); +} + +export function fileUpdated(app: DBAppType, file: FileType) { + const path = Path.join(pathToApp(app.externalId), file.path); + return fs.writeFile(path, file.source, 'utf-8'); +} + +async function copy(src: string, dest: string) { + const stat = await fs.stat(src); + if (stat.isDirectory()) { + return copyDir(src, dest); + } else { + return fs.copyFile(src, dest); + } +} + +async function copyDir(srcDir: string, destDir: string) { + await fs.mkdir(destDir, { recursive: true }); + const files = await fs.readdir(srcDir); + for (const file of files) { + const srcFile = Path.resolve(srcDir, file); + const destFile = Path.resolve(destDir, file); + await copy(srcFile, destFile); + } +} + +// TODO: This does not scale. +export async function getProjectFiles(app: DBAppType) { + const projectDir = Path.join(APPS_DIR, app.externalId); + + const { files, directories } = await getDiskEntries(projectDir, { + exclude: ['node_modules', 'dist'], + }); + + const nestedFiles = await Promise.all( + directories.flatMap(async (dir) => { + const entries = await fs.readdir(Path.join(projectDir, dir.name), { + withFileTypes: true, + recursive: true, + }); + return entries.filter((entry) => entry.isFile()); + }), + ); + + const entries = [...files, ...nestedFiles.flat()]; + + return Promise.all( + entries.map(async (entry) => { + const fullPath = Path.join(entry.parentPath, entry.name); + const relativePath = Path.relative(projectDir, fullPath); + const contents = await fs.readFile(fullPath); + const binary = isBinary(entry.name); + const source = !binary ? contents.toString('utf-8') : `TODO: handle this`; + return { path: relativePath, source, binary }; + }), + ); +} + +async function getDiskEntries(projectDir: string, options: { exclude: string[] }) { + const result: { files: Dirent[]; directories: Dirent[] } = { + files: [], + directories: [], + }; + + for (const entry of await fs.readdir(projectDir, { withFileTypes: true })) { + if (options.exclude.includes(entry.name)) { + continue; + } + + if (entry.isFile()) { + result.files.push(entry); + } else { + result.directories.push(entry); + } + } + + return result; +} + +// TODO: This does not scale. +// What's the best way to know whether a file is a "binary" +// file or not? Inspecting bytes for invalid utf8? +const TEXT_FILE_EXTENSIONS = [ + '.ts', + '.cts', + '.mts', + '.tsx', + '.js', + '.cjs', + '.mjs', + '.jsx', + '.md', + '.markdown', + '.json', + '.css', + '.html', +]; + +function isBinary(basename: string) { + const isDotfile = basename.startsWith('.'); // Assume these are text for now, e.g., .gitignore + const isTextFile = TEXT_FILE_EXTENSIONS.includes(Path.extname(basename)); + return !(isDotfile || isTextFile); +} diff --git a/packages/api/apps/schemas.mts b/packages/api/apps/schemas.mts new file mode 100644 index 00000000..426061ff --- /dev/null +++ b/packages/api/apps/schemas.mts @@ -0,0 +1,9 @@ +import z from 'zod'; + +export const CreateAppSchema = z.object({ + name: z.string(), + language: z.union([z.literal('typescript'), z.literal('javascript')]), + prompt: z.string().optional(), +}); + +export type CreateAppSchemaType = z.infer; diff --git a/packages/api/apps/templates/README.md b/packages/api/apps/templates/README.md new file mode 100644 index 00000000..bc7db2c6 --- /dev/null +++ b/packages/api/apps/templates/README.md @@ -0,0 +1 @@ +These templates were copied from https://github.com/vitejs/vite/tree/main/packages/create-vite diff --git a/packages/api/apps/templates/react-javascript/eslint.config.js b/packages/api/apps/templates/react-javascript/eslint.config.js new file mode 100644 index 00000000..238d2e4e --- /dev/null +++ b/packages/api/apps/templates/react-javascript/eslint.config.js @@ -0,0 +1,38 @@ +import js from '@eslint/js' +import globals from 'globals' +import react from 'eslint-plugin-react' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' + +export default [ + { ignores: ['dist'] }, + { + files: ['**/*.{js,jsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + settings: { react: { version: '18.3' } }, + plugins: { + react, + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...js.configs.recommended.rules, + ...react.configs.recommended.rules, + ...react.configs['jsx-runtime'].rules, + ...reactHooks.configs.recommended.rules, + 'react/jsx-no-target-blank': 'off', + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +] diff --git a/packages/api/apps/templates/react-javascript/index.html b/packages/api/apps/templates/react-javascript/index.html new file mode 100644 index 00000000..0c589ecc --- /dev/null +++ b/packages/api/apps/templates/react-javascript/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + + +
+ + + diff --git a/packages/api/apps/templates/react-javascript/package.json b/packages/api/apps/templates/react-javascript/package.json new file mode 100644 index 00000000..57775faf --- /dev/null +++ b/packages/api/apps/templates/react-javascript/package.json @@ -0,0 +1,28 @@ +{ + "name": "vite-react-starter", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@eslint/js": "^9.10.0", + "@types/react": "^18.3.6", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "eslint": "^9.10.0", + "eslint-plugin-react": "^7.36.1", + "eslint-plugin-react-hooks": "^5.1.0-rc.0", + "eslint-plugin-react-refresh": "^0.4.12", + "globals": "^15.9.0", + "vite": "^5.4.6" + } +} diff --git a/packages/api/apps/templates/react-javascript/public/vite.svg b/packages/api/apps/templates/react-javascript/public/vite.svg new file mode 100644 index 00000000..e7b8dfb1 --- /dev/null +++ b/packages/api/apps/templates/react-javascript/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/api/apps/templates/react-javascript/src/App.css b/packages/api/apps/templates/react-javascript/src/App.css new file mode 100644 index 00000000..b9d355df --- /dev/null +++ b/packages/api/apps/templates/react-javascript/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/packages/api/apps/templates/react-javascript/src/App.jsx b/packages/api/apps/templates/react-javascript/src/App.jsx new file mode 100644 index 00000000..b8b8473a --- /dev/null +++ b/packages/api/apps/templates/react-javascript/src/App.jsx @@ -0,0 +1,35 @@ +import { useState } from 'react' +import reactLogo from './assets/react.svg' +import viteLogo from '/vite.svg' +import './App.css' + +function App() { + const [count, setCount] = useState(0) + + return ( + <> +
+ + Vite logo + + + React logo + +
+

Vite + React

+
+ +

+ Edit src/App.jsx and save to test HMR +

+
+

+ Click on the Vite and React logos to learn more +

+ + ) +} + +export default App diff --git a/packages/api/apps/templates/react-javascript/src/assets/react.svg b/packages/api/apps/templates/react-javascript/src/assets/react.svg new file mode 100644 index 00000000..6c87de9b --- /dev/null +++ b/packages/api/apps/templates/react-javascript/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/api/apps/templates/react-javascript/src/index.css b/packages/api/apps/templates/react-javascript/src/index.css new file mode 100644 index 00000000..6119ad9a --- /dev/null +++ b/packages/api/apps/templates/react-javascript/src/index.css @@ -0,0 +1,68 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/packages/api/apps/templates/react-javascript/src/main.jsx b/packages/api/apps/templates/react-javascript/src/main.jsx new file mode 100644 index 00000000..89f91e54 --- /dev/null +++ b/packages/api/apps/templates/react-javascript/src/main.jsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import App from './App.jsx' +import './index.css' + +createRoot(document.getElementById('root')).render( + + + , +) diff --git a/packages/api/apps/templates/react-javascript/vite.config.js b/packages/api/apps/templates/react-javascript/vite.config.js new file mode 100644 index 00000000..5a33944a --- /dev/null +++ b/packages/api/apps/templates/react-javascript/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], +}) diff --git a/packages/api/apps/templates/react-typescript/eslint.config.js b/packages/api/apps/templates/react-typescript/eslint.config.js new file mode 100644 index 00000000..092408a9 --- /dev/null +++ b/packages/api/apps/templates/react-typescript/eslint.config.js @@ -0,0 +1,28 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +) diff --git a/packages/api/apps/templates/react-typescript/index.html b/packages/api/apps/templates/react-typescript/index.html new file mode 100644 index 00000000..e4b78eae --- /dev/null +++ b/packages/api/apps/templates/react-typescript/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/packages/api/apps/templates/react-typescript/package.json b/packages/api/apps/templates/react-typescript/package.json new file mode 100644 index 00000000..7eeeb026 --- /dev/null +++ b/packages/api/apps/templates/react-typescript/package.json @@ -0,0 +1,29 @@ +{ + "name": "vite-react-typescript-starter", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@eslint/js": "^9.10.0", + "@types/react": "^18.3.6", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "eslint": "^9.10.0", + "eslint-plugin-react-hooks": "^5.1.0-rc.0", + "eslint-plugin-react-refresh": "^0.4.12", + "globals": "^15.9.0", + "typescript": "^5.5.3", + "typescript-eslint": "^8.6.0", + "vite": "^5.4.6" + } +} diff --git a/packages/api/apps/templates/react-typescript/public/vite.svg b/packages/api/apps/templates/react-typescript/public/vite.svg new file mode 100644 index 00000000..e7b8dfb1 --- /dev/null +++ b/packages/api/apps/templates/react-typescript/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/api/apps/templates/react-typescript/src/App.css b/packages/api/apps/templates/react-typescript/src/App.css new file mode 100644 index 00000000..b9d355df --- /dev/null +++ b/packages/api/apps/templates/react-typescript/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/packages/api/apps/templates/react-typescript/src/App.tsx b/packages/api/apps/templates/react-typescript/src/App.tsx new file mode 100644 index 00000000..afe48ac7 --- /dev/null +++ b/packages/api/apps/templates/react-typescript/src/App.tsx @@ -0,0 +1,35 @@ +import { useState } from 'react' +import reactLogo from './assets/react.svg' +import viteLogo from '/vite.svg' +import './App.css' + +function App() { + const [count, setCount] = useState(0) + + return ( + <> +
+ + Vite logo + + + React logo + +
+

Vite + React

+
+ +

+ Edit src/App.tsx and save to test HMR +

+
+

+ Click on the Vite and React logos to learn more +

+ + ) +} + +export default App diff --git a/packages/api/apps/templates/react-typescript/src/assets/react.svg b/packages/api/apps/templates/react-typescript/src/assets/react.svg new file mode 100644 index 00000000..6c87de9b --- /dev/null +++ b/packages/api/apps/templates/react-typescript/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/api/apps/templates/react-typescript/src/index.css b/packages/api/apps/templates/react-typescript/src/index.css new file mode 100644 index 00000000..6119ad9a --- /dev/null +++ b/packages/api/apps/templates/react-typescript/src/index.css @@ -0,0 +1,68 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/packages/api/apps/templates/react-typescript/src/main.tsx b/packages/api/apps/templates/react-typescript/src/main.tsx new file mode 100644 index 00000000..6f4ac9bc --- /dev/null +++ b/packages/api/apps/templates/react-typescript/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import App from './App.tsx' +import './index.css' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/packages/api/apps/templates/react-typescript/src/vite-env.d.ts b/packages/api/apps/templates/react-typescript/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/packages/api/apps/templates/react-typescript/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/api/apps/templates/react-typescript/tsconfig.app.json b/packages/api/apps/templates/react-typescript/tsconfig.app.json new file mode 100644 index 00000000..f0a23505 --- /dev/null +++ b/packages/api/apps/templates/react-typescript/tsconfig.app.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/packages/api/apps/templates/react-typescript/tsconfig.json b/packages/api/apps/templates/react-typescript/tsconfig.json new file mode 100644 index 00000000..1ffef600 --- /dev/null +++ b/packages/api/apps/templates/react-typescript/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/packages/api/apps/templates/react-typescript/tsconfig.node.json b/packages/api/apps/templates/react-typescript/tsconfig.node.json new file mode 100644 index 00000000..0d3d7144 --- /dev/null +++ b/packages/api/apps/templates/react-typescript/tsconfig.node.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/packages/api/apps/templates/react-typescript/vite.config.ts b/packages/api/apps/templates/react-typescript/vite.config.ts new file mode 100644 index 00000000..627a3196 --- /dev/null +++ b/packages/api/apps/templates/react-typescript/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], +}); diff --git a/packages/api/apps/utils.mts b/packages/api/apps/utils.mts new file mode 100644 index 00000000..3636400b --- /dev/null +++ b/packages/api/apps/utils.mts @@ -0,0 +1,9 @@ +// Copied from https://github.com/vitejs/vite/tree/main/packages/create-vite +export function toValidPackageName(projectName: string) { + return projectName + .trim() + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/^[._]/, '') + .replace(/[^a-z\d\-~]+/g, '-'); +} diff --git a/packages/api/constants.mts b/packages/api/constants.mts index 1bf41703..d742b559 100644 --- a/packages/api/constants.mts +++ b/packages/api/constants.mts @@ -11,6 +11,7 @@ const _dirname = path.dirname(_filename); export const HOME_DIR = os.homedir(); export const SRCBOOK_DIR = path.join(HOME_DIR, '.srcbook'); export const SRCBOOKS_DIR = path.join(SRCBOOK_DIR, 'srcbooks'); +export const APPS_DIR = path.join(SRCBOOK_DIR, 'apps'); export const DIST_DIR = _dirname; export const PROMPTS_DIR = path.join(DIST_DIR, 'prompts'); export const IS_PRODUCTION = process.env.NODE_ENV === 'production'; diff --git a/packages/api/db/schema.mts b/packages/api/db/schema.mts index 45eb091f..1bbf9783 100644 --- a/packages/api/db/schema.mts +++ b/packages/api/db/schema.mts @@ -1,3 +1,4 @@ +import { sql } from 'drizzle-orm'; import { sqliteTable, text, integer, unique } from 'drizzle-orm/sqlite-core'; import { randomid } from '@srcbook/shared'; @@ -43,3 +44,18 @@ export const secretsToSession = sqliteTable( ); export type SecretsToSession = typeof secretsToSession.$inferSelect; + +export const apps = sqliteTable('apps', { + id: integer('id').primaryKey(), + name: text('name').notNull(), + language: text('language').notNull(), + externalId: text('external_id').notNull().unique(), + createdAt: integer('created_at', { mode: 'timestamp' }) + .notNull() + .default(sql`(unixepoch())`), + updatedAt: integer('updated_at', { mode: 'timestamp' }) + .notNull() + .default(sql`(unixepoch())`), +}); + +export type App = typeof apps.$inferSelect; diff --git a/packages/api/drizzle/0010_create_apps.sql b/packages/api/drizzle/0010_create_apps.sql new file mode 100644 index 00000000..c9934eb7 --- /dev/null +++ b/packages/api/drizzle/0010_create_apps.sql @@ -0,0 +1,8 @@ +CREATE TABLE `apps` ( + `id` integer PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `language` text NOT NULL, + `external_id` text NOT NULL, + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + `updated_at` integer DEFAULT (unixepoch()) NOT NULL +); diff --git a/packages/api/drizzle/0011_apps_external_id_unique.sql b/packages/api/drizzle/0011_apps_external_id_unique.sql new file mode 100644 index 00000000..87759d51 --- /dev/null +++ b/packages/api/drizzle/0011_apps_external_id_unique.sql @@ -0,0 +1 @@ +CREATE UNIQUE INDEX `apps_external_id_unique` ON `apps` (`external_id`); \ No newline at end of file diff --git a/packages/api/drizzle/meta/0010_snapshot.json b/packages/api/drizzle/meta/0010_snapshot.json new file mode 100644 index 00000000..680379b3 --- /dev/null +++ b/packages/api/drizzle/meta/0010_snapshot.json @@ -0,0 +1,253 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "aeb418fb-06df-4fc2-8afc-f18d95014b46", + "prevId": "fd7a01ac-c2a9-4369-a2e6-f47a691ba1a2", + "tables": { + "apps": { + "name": "apps", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "apps_external_id_unique": { + "name": "apps_external_id_unique", + "columns": [ + "external_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "config": { + "name": "config", + "columns": { + "base_dir": { + "name": "base_dir", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_language": { + "name": "default_language", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'typescript'" + }, + "openai_api_key": { + "name": "openai_api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "anthropic_api_key": { + "name": "anthropic_api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled_analytics": { + "name": "enabled_analytics", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "srcbook_installation_id": { + "name": "srcbook_installation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'3rh9ht1ndd07a0j6detu5k4an8'" + }, + "ai_provider": { + "name": "ai_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'openai'" + }, + "ai_model": { + "name": "ai_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'gpt-4o'" + }, + "ai_base_url": { + "name": "ai_base_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription_email": { + "name": "subscription_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "secrets": { + "name": "secrets", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "secrets_name_unique": { + "name": "secrets_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "secrets_to_sessions": { + "name": "secrets_to_sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret_id": { + "name": "secret_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "secrets_to_sessions_session_id_secret_id_unique": { + "name": "secrets_to_sessions_session_id_secret_id_unique", + "columns": [ + "session_id", + "secret_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "secrets_to_sessions_secret_id_secrets_id_fk": { + "name": "secrets_to_sessions_secret_id_secrets_id_fk", + "tableFrom": "secrets_to_sessions", + "tableTo": "secrets", + "columnsFrom": [ + "secret_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/api/drizzle/meta/_journal.json b/packages/api/drizzle/meta/_journal.json index b8cac8c7..73cc4049 100644 --- a/packages/api/drizzle/meta/_journal.json +++ b/packages/api/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1726250539939, "tag": "0009_secret_session_unique", "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1726808187994, + "tag": "0010_create_apps", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/api/exec.mts b/packages/api/exec.mts index 84aea713..28878db9 100644 --- a/packages/api/exec.mts +++ b/packages/api/exec.mts @@ -18,6 +18,10 @@ export type NPMInstallRequestType = BaseExecRequestType & { args?: Array; }; +type NpxRequestType = BaseExecRequestType & { + args: Array; +}; + type SpawnCallRequestType = { cwd: string; env: NodeJS.ProcessEnv; @@ -147,3 +151,14 @@ export function npmInstall(options: NPMInstallRequestType) { env: process.env, }); } + +/** + * Run vite. + */ +export function vite(options: NpxRequestType) { + return spawnCall({ + ...options, + command: Path.join(options.cwd, 'node_modules', '.bin', 'vite'), + env: process.env, + }); +} diff --git a/packages/api/processes.mts b/packages/api/processes.mts index d4d5eec4..641cb7fd 100644 --- a/packages/api/processes.mts +++ b/packages/api/processes.mts @@ -1,6 +1,6 @@ import { ChildProcess } from 'node:child_process'; -class Processes { +export class Processes { private processes: Record = {}; add(sessionId: string, cellId: string, process: ChildProcess) { diff --git a/packages/api/server/channels/app.mts b/packages/api/server/channels/app.mts new file mode 100644 index 00000000..cd2dcd62 --- /dev/null +++ b/packages/api/server/channels/app.mts @@ -0,0 +1,134 @@ +import { ChildProcess } from 'node:child_process'; + +import { + PreviewStartPayloadSchema, + PreviewStopPayloadSchema, + FileUpdatedPayloadSchema, + FileType, + FileUpdatedPayloadType, + PreviewStartPayloadType, + PreviewStopPayloadType, +} from '@srcbook/shared'; + +import WebSocketServer, { + type MessageContextType, + type ConnectionContextType, +} from '../ws-client.mjs'; +import { loadApp } from '../../apps/app.mjs'; +import { fileUpdated, getProjectFiles, pathToApp } from '../../apps/disk.mjs'; +import { vite } from '../../exec.mjs'; + +type AppContextType = MessageContextType<'appId'>; + +const processes = new Map(); + +async function previewStart( + _payload: PreviewStartPayloadType, + context: AppContextType, + conn: ConnectionContextType, +) { + const app = await loadApp(context.params.appId); + + if (!app) { + return; + } + + const existingProcess = processes.get(app.externalId); + + if (existingProcess) { + conn.reply(`app:${app.externalId}`, 'preview:status', { url: null, status: 'running' }); + return; + } + + const process = vite({ + // TODO: Configure port and fail if port in use + args: [], + cwd: pathToApp(app.externalId), + stdout: (data) => { + console.log(data.toString('utf8')); + }, + stderr: (data) => { + console.error(data.toString('utf8')); + }, + onExit: (_code) => { + processes.delete(app.externalId); + conn.reply(`app:${app.externalId}`, 'preview:status', { + url: null, + status: 'stopped', + }); + }, + }); + + processes.set(app.externalId, process); + + // TODO: better way to know when the server is ready + setTimeout(() => { + conn.reply(`app:${app.externalId}`, 'preview:status', { + url: 'http://localhost:5174/', + status: 'running', + }); + }, 500); +} + +async function previewStop( + _payload: PreviewStopPayloadType, + context: AppContextType, + conn: ConnectionContextType, +) { + const app = await loadApp(context.params.appId); + + if (!app) { + return; + } + + const process = processes.get(app.externalId); + + if (!process) { + conn.reply(`app:${app.externalId}`, 'preview:status', { url: null, status: 'stopped' }); + return; + } + + process.kill('SIGTERM'); + + conn.reply(`app:${app.externalId}`, 'preview:status', { url: null, status: 'stopped' }); +} + +async function onFileUpdated(payload: FileUpdatedPayloadType, context: AppContextType) { + const app = await loadApp(context.params.appId); + + if (!app) { + return; + } + + fileUpdated(app, payload.file as FileType); +} + +export function register(wss: WebSocketServer) { + wss + .channel('app:') + .on('preview:start', PreviewStartPayloadSchema, previewStart) + .on('preview:stop', PreviewStopPayloadSchema, previewStop) + .on('file:updated', FileUpdatedPayloadSchema, onFileUpdated) + .onJoin(async (topic, ws) => { + const app = await loadApp(topic.split(':')[1]!); + + // TODO: disconnect + if (!app) { + return; + } + + const existingProcess = processes.get(app.externalId); + + ws.send( + JSON.stringify([ + topic, + 'preview:status', + { url: null, status: existingProcess ? 'running' : 'stopped' }, + ]), + ); + + for (const file of await getProjectFiles(app)) { + ws.send(JSON.stringify([topic, 'file', { file }])); + } + }); +} diff --git a/packages/api/server/http.mts b/packages/api/server/http.mts index 90c10ca3..5903eb11 100644 --- a/packages/api/server/http.mts +++ b/packages/api/server/http.mts @@ -2,7 +2,7 @@ import Path from 'node:path'; import { posthog } from '../posthog-client.mjs'; import fs from 'node:fs/promises'; import { SRCBOOKS_DIR } from '../constants.mjs'; -import express, { type Application } from 'express'; +import express, { type Application, type Response } from 'express'; import cors from 'cors'; import { createSession, @@ -34,6 +34,8 @@ import { readdir } from '../fs-utils.mjs'; import { EXAMPLE_SRCBOOKS } from '../srcbook/examples.mjs'; import { pathToSrcbook } from '../srcbook/path.mjs'; import { isSrcmdPath } from '../srcmd/paths.mjs'; +import { loadApps, loadApp, createApp, serializeApp, deleteApp } from '../apps/app.mjs'; +import { CreateAppSchema } from '../apps/schemas.mjs'; const app: Application = express(); @@ -390,6 +392,77 @@ router.post('/subscribe', cors(), async (req, res) => { } }); +function error500(res: Response, e: Error) { + const error = e as unknown as Error; + console.error(error); + return res.status(500).json({ error: 'An unexpected error occurred.' }); +} + +router.options('/apps', cors()); +router.post('/apps', cors(), async (req, res) => { + const result = CreateAppSchema.safeParse(req.body); + + if (result.success === false) { + const errors = result.error.errors.map((error) => error.message); + return res.status(400).json({ errors }); + } + + const attrs = result.data; + + posthog.capture({ + event: 'user created app', + properties: { language: attrs.language, withPrompt: typeof attrs.prompt === 'string' }, + }); + + try { + const app = await createApp(attrs); + return res.json({ data: serializeApp(app) }); + } catch (e) { + return error500(res, e as Error); + } +}); + +router.options('/apps', cors()); +router.get('/apps', cors(), async (req, res) => { + const sort = req.query.sort === 'desc' ? 'desc' : 'asc'; + + try { + const apps = await loadApps(sort); + return res.json({ data: apps.map(serializeApp) }); + } catch (e) { + return error500(res, e as Error); + } +}); + +router.options('/apps/:id', cors()); +router.get('/apps/:id', cors(), async (req, res) => { + const { id } = req.params; + + try { + const app = await loadApp(id); + + if (!app) { + return res.status(404).json({ error: 'App not found' }); + } + + return res.json({ data: serializeApp(app) }); + } catch (e) { + return error500(res, e as Error); + } +}); + +router.options('/apps/:id', cors()); +router.delete('/apps/:id', cors(), async (req, res) => { + const { id } = req.params; + + try { + await deleteApp(id); + return res.json({ deleted: true }); + } catch (e) { + return error500(res, e as Error); + } +}); + app.use('/api', router); export default app; diff --git a/packages/api/server/ws-client.mts b/packages/api/server/ws-client.mts index d4d44d45..3f1f14ac 100644 --- a/packages/api/server/ws-client.mts +++ b/packages/api/server/ws-client.mts @@ -60,6 +60,8 @@ export class Channel { } > = {}; + onJoinCallback: (topic: string, ws: WebSocket) => void = () => {}; + constructor(topic: string) { this.topic = topic; this.parts = this.splitIntoParts(topic); @@ -127,6 +129,11 @@ export class Channel { this.events[event] = { schema, handler }; return this; } + + onJoin(callback: (topic: string, ws: WebSocket) => void) { + this.onJoinCallback = callback; + return this; + } } type ConnectionType = { @@ -202,8 +209,11 @@ export default class WebSocketServer { return; } + const { channel, match } = channelMatch; + if (event === 'subscribe') { conn.subscriptions.push(topic); + channel.onJoinCallback(topic, conn.socket); return; } @@ -212,8 +222,6 @@ export default class WebSocketServer { return; } - const { channel, match } = channelMatch; - const registeredEvent = channel.events[event]; if (registeredEvent === undefined) { diff --git a/packages/api/server/ws.mts b/packages/api/server/ws.mts index 0c303289..bdfb159a 100644 --- a/packages/api/server/ws.mts +++ b/packages/api/server/ws.mts @@ -63,6 +63,7 @@ import WebSocketServer, { MessageContextType } from './ws-client.mjs'; import { filenameFromPath, pathToCodeFile } from '../srcbook/path.mjs'; import { normalizeDiagnostic } from '../tsserver/utils.mjs'; import { removeCodeCellFromDisk } from '../srcbook/index.mjs'; +import { register as registerAppChannel } from './channels/app.mjs'; type SessionsContextType = MessageContextType<'sessionId'>; @@ -883,4 +884,6 @@ wss getCompletions, ); +registerAppChannel(wss); + export default wss; diff --git a/packages/shared/index.mts b/packages/shared/index.mts index 2d0c3093..61c47c40 100644 --- a/packages/shared/index.mts +++ b/packages/shared/index.mts @@ -1,6 +1,8 @@ +export * from './src/schemas/apps.mjs'; export * from './src/schemas/cells.mjs'; export * from './src/schemas/tsserver.mjs'; export * from './src/schemas/websockets.mjs'; +export * from './src/types/apps.mjs'; export * from './src/types/cells.mjs'; export * from './src/types/tsserver.mjs'; export * from './src/types/websockets.mjs'; diff --git a/packages/shared/src/schemas/apps.mts b/packages/shared/src/schemas/apps.mts new file mode 100644 index 00000000..5221bfd6 --- /dev/null +++ b/packages/shared/src/schemas/apps.mts @@ -0,0 +1,7 @@ +import z from 'zod'; + +export const FileSchema = z.object({ + path: z.string(), + source: z.string(), + binary: z.boolean(), +}); diff --git a/packages/shared/src/schemas/files.mts b/packages/shared/src/schemas/files.mts new file mode 100644 index 00000000..e69de29b diff --git a/packages/shared/src/schemas/websockets.mts b/packages/shared/src/schemas/websockets.mts index 872cceef..5c0f4b1c 100644 --- a/packages/shared/src/schemas/websockets.mts +++ b/packages/shared/src/schemas/websockets.mts @@ -7,6 +7,7 @@ import { TsServerQuickInfoResponseSchema, TsServerCompletionEntriesSchema, } from './tsserver.mjs'; +import { FileSchema } from './apps.mjs'; // A _message_ over websockets export const WebSocketMessageSchema = z.tuple([ @@ -140,3 +141,36 @@ export const TsConfigUpdatePayloadSchema = z.object({ export const TsConfigUpdatedPayloadSchema = z.object({ source: z.string(), }); + +////////// +// APPS // +////////// + +export const FilePayloadSchema = z.object({ + file: FileSchema, +}); + +export const FileCreatedPayloadSchema = z.object({ + file: FileSchema, +}); + +export const FileUpdatedPayloadSchema = z.object({ + file: FileSchema.partial(), +}); + +export const FileRenamedPayloadSchema = z.object({ + oldPath: z.string(), + newPath: z.string(), +}); + +export const FileDeletedPayloadSchema = z.object({ + path: z.string(), +}); + +export const PreviewStatusPayloadSchema = z.object({ + url: z.string().nullable(), + status: z.enum(['booting', 'running', 'stopped']), +}); + +export const PreviewStartPayloadSchema = z.object({}); +export const PreviewStopPayloadSchema = z.object({}); diff --git a/packages/shared/src/types/apps.mts b/packages/shared/src/types/apps.mts new file mode 100644 index 00000000..001a35d8 --- /dev/null +++ b/packages/shared/src/types/apps.mts @@ -0,0 +1,14 @@ +import z from 'zod'; + +import { FileSchema } from '../schemas/apps.mjs'; +import { CodeLanguageType } from './cells.mjs'; + +export type AppType = { + id: string; + name: string; + language: CodeLanguageType; + createdAt: number; + updatedAt: number; +}; + +export type FileType = z.infer; diff --git a/packages/shared/src/types/websockets.mts b/packages/shared/src/types/websockets.mts index 02f8234d..4c25f5bf 100644 --- a/packages/shared/src/types/websockets.mts +++ b/packages/shared/src/types/websockets.mts @@ -29,6 +29,14 @@ import { TsServerDefinitionLocationRequestPayloadSchema, TsServerDefinitionLocationResponsePayloadSchema, TsServerCompletionEntriesPayloadSchema, + FilePayloadSchema, + FileCreatedPayloadSchema, + FileUpdatedPayloadSchema, + FileRenamedPayloadSchema, + FileDeletedPayloadSchema, + PreviewStatusPayloadSchema, + PreviewStopPayloadSchema, + PreviewStartPayloadSchema, } from '../schemas/websockets.mjs'; export type CellExecPayloadType = z.infer; @@ -80,3 +88,16 @@ export type TsServerDefinitionLocationResponsePayloadType = z.infer< export type TsServerCompletionEntriesPayloadType = z.infer< typeof TsServerCompletionEntriesPayloadSchema >; + +////////// +// APPS // +////////// + +export type FilePayloadType = z.infer; +export type FileCreatedPayloadType = z.infer; +export type FileUpdatedPayloadType = z.infer; +export type FileRenamedPayloadType = z.infer; +export type FileDeletedPayloadType = z.infer; +export type PreviewStatusPayloadType = z.infer; +export type PreviewStartPayloadType = z.infer; +export type PreviewStopPayloadType = z.infer; diff --git a/packages/web/package.json b/packages/web/package.json index 4df204be..979a78c9 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -14,6 +14,8 @@ }, "dependencies": { "@codemirror/autocomplete": "^6.18.1", + "@codemirror/lang-css": "^6.3.0", + "@codemirror/lang-html": "^6.4.9", "@codemirror/lang-json": "^6.0.1", "@codemirror/lang-markdown": "^6.2.5", "@codemirror/lang-javascript": "^6.2.2", diff --git a/packages/web/src/clients/http/apps.ts b/packages/web/src/clients/http/apps.ts new file mode 100644 index 00000000..b5fe3252 --- /dev/null +++ b/packages/web/src/clients/http/apps.ts @@ -0,0 +1,63 @@ +import type { AppType, CodeLanguageType } from '@srcbook/shared'; +import SRCBOOK_CONFIG from '@/config'; + +const API_BASE_URL = `${SRCBOOK_CONFIG.api.origin}/api`; + +export async function createApp(request: { + name: string; + prompt?: string; + language: CodeLanguageType; +}): Promise<{ data: AppType }> { + const response = await fetch(API_BASE_URL + '/apps', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(request), + }); + + if (!response.ok) { + console.error(response); + throw new Error('Request failed'); + } + + return response.json(); +} + +export async function deleteApp(id: string): Promise { + const response = await fetch(API_BASE_URL + '/apps/' + id, { + method: 'DELETE', + headers: { 'content-type': 'application/json' }, + }); + + if (!response.ok) { + console.error(response); + throw new Error('Request failed'); + } +} + +export async function loadApps(sort: 'asc' | 'desc'): Promise<{ data: AppType[] }> { + const response = await fetch(API_BASE_URL + '/apps?sort=' + sort, { + method: 'GET', + headers: { 'content-type': 'application/json' }, + }); + + if (!response.ok) { + console.error(response); + throw new Error('Request failed'); + } + + return response.json(); +} + +export async function loadApp(id: string): Promise<{ data: AppType }> { + const response = await fetch(API_BASE_URL + '/apps/' + id, { + method: 'GET', + headers: { 'content-type': 'application/json' }, + }); + + if (!response.ok) { + console.error(response); + throw new Error('Request failed'); + } + + return response.json(); +} diff --git a/packages/web/src/clients/websocket/index.ts b/packages/web/src/clients/websocket/index.ts index 40de71d7..c66d9cc4 100644 --- a/packages/web/src/clients/websocket/index.ts +++ b/packages/web/src/clients/websocket/index.ts @@ -27,6 +27,14 @@ import { TsServerDefinitionLocationResponsePayloadSchema, TsServerDefinitionLocationRequestPayloadSchema, TsServerCompletionEntriesPayloadSchema, + FileCreatedPayloadSchema, + FileUpdatedPayloadSchema, + FileRenamedPayloadSchema, + FileDeletedPayloadSchema, + FilePayloadSchema, + PreviewStatusPayloadSchema, + PreviewStartPayloadSchema, + PreviewStopPayloadSchema, } from '@srcbook/shared'; import Channel from '@/clients/websocket/channel'; import WebSocketClient from '@/clients/websocket/client'; @@ -82,3 +90,33 @@ export class SessionChannel extends Channel< }); } } + +const IncomingAppEvents = { + file: FilePayloadSchema, + 'preview:status': PreviewStatusPayloadSchema, +}; + +const OutgoingAppEvents = { + 'file:created': FileCreatedPayloadSchema, + 'file:updated': FileUpdatedPayloadSchema, + 'file:renamed': FileRenamedPayloadSchema, + 'file:deleted': FileDeletedPayloadSchema, + 'preview:start': PreviewStartPayloadSchema, + 'preview:stop': PreviewStopPayloadSchema, +}; + +export class AppChannel extends Channel { + appId: string; + + static create(appId: string) { + return new AppChannel(appId); + } + + constructor(appId: string) { + super(client, `app:${appId}`, { + incoming: IncomingAppEvents, + outgoing: OutgoingAppEvents, + }); + this.appId = appId; + } +} diff --git a/packages/web/src/components/apps/create-modal.tsx b/packages/web/src/components/apps/create-modal.tsx new file mode 100644 index 00000000..4f5ab4cd --- /dev/null +++ b/packages/web/src/components/apps/create-modal.tsx @@ -0,0 +1,184 @@ +import { useState } from 'react'; +import { cn } from '@/lib/utils'; +import { Input } from '@srcbook/components/src/components/ui/input'; +import { Button } from '@srcbook/components/src/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@srcbook/components/src/components/ui/dialog'; + +import { HelpCircle, Sparkles } from 'lucide-react'; +import { CodeLanguageType } from '@srcbook/shared'; +import { JavaScriptLogo, TypeScriptLogo } from '../logos'; +import { Textarea } from '@srcbook/components/src/components/ui/textarea'; +import { + TooltipContent, + TooltipProvider, + TooltipTrigger, + Tooltip, +} from '@srcbook/components/src/components/ui/tooltip'; + +type PropsType = { + onClose: () => void; + onCreate: (name: string, language: CodeLanguageType, prompt?: string) => Promise; +}; + +export default function CreateAppModal({ onClose, onCreate }: PropsType) { + const [name, setName] = useState(''); + const [prompt, setPrompt] = useState(''); + const [language, setLanguage] = useState('typescript'); + + const [submitting, setSubmitting] = useState(false); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + e.stopPropagation(); + + if (submitting) { + return; + } + + setSubmitting(true); + + try { + await onCreate(name, language, prompt.trim() === '' ? undefined : prompt); + } finally { + setSubmitting(false); + } + } + + return ( + { + if (open === false) { + onClose(); + } + }} + > + + + Create application + + Create a React app powered by Vite and Tailwind. + + +
+
+ setLanguage('typescript')} + className="font-mono text-sm" + > + + TypeScript + + setLanguage('javascript')} + className="font-mono text-sm" + > + + JavaScript + +
+ +
+ + setName(e.currentTarget.value)} + placeholder="Spotify Light" + /> +
+ +
+
+ + + + + + + + Optionally use AI to scaffold your app + + + +
+ +
+ + + + + +
+
+
+ ); +} + +function LanguageSelector(props: { + id: string; + name: string; + value: CodeLanguageType; + selected: boolean; + onSelect: () => void; + className?: string; + children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/packages/web/src/components/apps/header.tsx b/packages/web/src/components/apps/header.tsx new file mode 100644 index 00000000..6250cd60 --- /dev/null +++ b/packages/web/src/components/apps/header.tsx @@ -0,0 +1,184 @@ +import { NavLink, Link, useNavigate } from 'react-router-dom'; +import { + PlusIcon, + ChevronDownIcon, + ShareIcon, + Trash2Icon, + StopCircle, + PlayCircle, +} from 'lucide-react'; +import type { AppType, CodeLanguageType } from '@srcbook/shared'; + +import { SrcbookLogo } from '@/components/logos'; +import { Button } from '@srcbook/components/src/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@srcbook/components/src/components/ui/dropdown-menu'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@srcbook/components/src/components/ui/tooltip'; +import DeleteAppModal from '../delete-app-dialog'; +import { useState } from 'react'; +import CreateAppModal from './create-modal'; +import { createApp } from '@/clients/http/apps'; +import { cn } from '@/lib/utils'; +import { usePreview } from './use-preview'; + +type PropsType = { + app: AppType; + apps: AppType[]; + className?: string; +}; + +export default function AppHeader(props: PropsType) { + const [showCreateModal, setShowCreateModal] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + + const { start: startPreview, stop: stopPreview, status: previewStatus } = usePreview(); + + function togglePreview() { + if (previewStatus === 'running') { + stopPreview(); + } else if (previewStatus === 'stopped') { + startPreview(); + } + } + + const navigate = useNavigate(); + + const app = props.app; + const apps = props.apps.filter((a) => a.id !== app.id); + + async function onCreateApp(name: string, language: CodeLanguageType, prompt?: string) { + const { data: app } = await createApp({ name, language, prompt }); + navigate(`/apps/${app.id}`); + setShowCreateModal(false); + } + + return ( + <> + {showCreateModal && ( + setShowCreateModal(false)} onCreate={onCreateApp} /> + )} + {showDeleteModal && ( + setShowDeleteModal(false)} + onDeleted={() => navigate('/')} + /> + )} +
+ +
+ + ); +} diff --git a/packages/web/src/components/apps/panels/ai.tsx b/packages/web/src/components/apps/panels/ai.tsx new file mode 100644 index 00000000..7e6c029e --- /dev/null +++ b/packages/web/src/components/apps/panels/ai.tsx @@ -0,0 +1,3 @@ +export default function AIPanel() { + return
AI
; +} diff --git a/packages/web/src/components/apps/panels/explorer.tsx b/packages/web/src/components/apps/panels/explorer.tsx new file mode 100644 index 00000000..61806f23 --- /dev/null +++ b/packages/web/src/components/apps/panels/explorer.tsx @@ -0,0 +1,47 @@ +import { Folder } from 'lucide-react'; +import { useFiles, type FileTreeType } from '../use-files'; +import { FileType } from '@srcbook/shared'; + +export default function ExplorerPanel() { + const { fileTree, openedFile, setOpenedFile } = useFiles(); + + return ( +
+ +
+ ); +} + +type FileTreePropsType = { + tree: FileTreeType; + openedFile: FileType | null; + setOpenedFile: (file: FileType) => void; +}; + +function FileTree({ tree, openedFile, setOpenedFile }: FileTreePropsType) { + return ( +
    + {tree.map((entry) => + entry.directory ? ( +
  • +
    + {entry.name} +
    + +
  • + ) : ( +
  • + +
  • + ), + )} +
+ ); +} diff --git a/packages/web/src/components/apps/sidebar.tsx b/packages/web/src/components/apps/sidebar.tsx new file mode 100644 index 00000000..e65fe198 --- /dev/null +++ b/packages/web/src/components/apps/sidebar.tsx @@ -0,0 +1,116 @@ +import { useState } from 'react'; + +import { BotMessageSquare, FilesIcon, FlagIcon, KeyboardIcon } from 'lucide-react'; +import { Button } from '@srcbook/components/src/components/ui/button'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@srcbook/components/src/components/ui/tooltip'; +import KeyboardShortcutsDialog from '../keyboard-shortcuts-dialog'; +import FeedbackDialog from '../feedback-dialog'; +import { cn } from '@/lib/utils'; +import ExplorerPanel from './panels/explorer'; +import AIPanel from './panels/ai'; +import { useFiles } from './use-files'; + +type PanelType = 'explorer' | 'ai'; + +export default function Sidebar() { + const { openedFile } = useFiles(); + + const [showShortcuts, setShowShortcuts] = useState(false); + const [showFeedback, setShowFeedback] = useState(false); + const [panel, _setPanel] = useState(openedFile === null ? 'explorer' : null); + + function setPanel(nextPanel: PanelType) { + _setPanel(nextPanel === panel ? null : nextPanel); + } + + return ( + <> + + + +
+
+
+ setPanel('explorer')}> + + + setPanel('ai')}> + + +
+
+ setShowShortcuts(true)} + > + + + setShowFeedback(true)} + > + + +
+
+ + {panel === 'explorer' && } + {panel === 'ai' && } + +
+ + ); +} + +function NavItemWithTooltip(props: { + children: React.ReactNode; + tooltipContent: string; + onClick: () => void; +}) { + return ( + + + + + + {props.tooltipContent} + + + ); +} + +function Panel(props: { open: boolean; children: React.ReactNode }) { + if (!props.open) { + return null; + } + + return ( +
+ {props.children} +
+ ); +} diff --git a/packages/web/src/components/apps/use-files.tsx b/packages/web/src/components/apps/use-files.tsx new file mode 100644 index 00000000..c54ec7a3 --- /dev/null +++ b/packages/web/src/components/apps/use-files.tsx @@ -0,0 +1,145 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useReducer, + useRef, + useState, +} from 'react'; + +import type { FilePayloadType, FileType } from '@srcbook/shared'; +import { AppChannel } from '@/clients/websocket'; + +export type DirEntryType = { directory: true; name: string; children: FileTreeType }; +export type FileEntryType = { directory: false; name: string; file: FileType }; +export type FileTreeType = Array; + +function createSortedFileTree(files: FileType[]): FileTreeType { + const tree = createFileTree(files); + sortTree(tree); + return tree; +} + +function sortTree(tree: FileTreeType) { + tree.sort((a, b) => { + if (a.directory) sortTree(a.children); + if (b.directory) sortTree(b.children); + if (a.directory && !b.directory) return -1; + if (!a.directory && b.directory) return 1; + return a.name.localeCompare(b.name); + }); +} + +function createFileTree(files: FileType[]): FileTreeType { + const result: FileTreeType = []; + + for (const file of files) { + let current = result; + + const parts = file.path.split('/'); + + if (parts.length === 1) { + current.push({ directory: false, name: file.path, file }); + continue; + } + + const lastIdx = parts.length - 1; + + for (let i = 0; i < lastIdx; i++) { + const dirEntry = current.find((entry) => entry.directory && entry.name === parts[i]) as + | DirEntryType + | undefined; + + if (!dirEntry) { + const next: DirEntryType = { directory: true, name: parts[i]!, children: [] }; + current.push(next); + current = next.children; + } else { + current = dirEntry.children; + } + } + + current.push({ directory: false, name: parts[lastIdx]!, file }); + } + + return result; +} + +export interface FilesContextValue { + files: FileType[]; + fileTree: FileTreeType; + openedFile: FileType | null; + setOpenedFile: React.Dispatch>; + createFile: (attrs: FileType) => void; + updateFile: (file: FileType, attrs: Partial) => void; + deleteFile: (file: FileType) => void; +} + +const FilesContext = createContext(undefined); + +type ProviderPropsType = { + channel: AppChannel; + children: React.ReactNode; +}; + +export function FilesProvider({ channel, children }: ProviderPropsType) { + // Because we use refs for our state, we need a way to trigger + // component re-renders when the ref state changes. + // + // https://legacy.reactjs.org/docs/hooks-faq.html#is-there-something-like-forceupdate + // + const [, forceComponentRerender] = useReducer((x) => x + 1, 0); + + const filesRef = useRef>({}); + + const [openedFile, setOpenedFile] = useState(null); + + useEffect(() => { + function onFile(payload: FilePayloadType) { + filesRef.current[payload.file.path] = payload.file; + forceComponentRerender(); + } + channel.on('file', onFile); + return () => channel.off('file', onFile); + }, [channel, forceComponentRerender]); + + const createFile = useCallback((file: FileType) => { + filesRef.current[file.path] = file; + forceComponentRerender(); + }, []); + + const updateFile = useCallback( + (file: FileType, attrs: Partial) => { + const updatedFile: FileType = { ...file, ...attrs }; + filesRef.current[file.path] = updatedFile; + channel.push('file:updated', { file: updatedFile }); + forceComponentRerender(); + }, + [channel], + ); + + const deleteFile = useCallback((file: FileType) => { + delete filesRef.current[file.path]; + forceComponentRerender(); + }, []); + + const files = Object.values(filesRef.current); + const fileTree = createSortedFileTree(files); + + const context: FilesContextValue = { + files, + fileTree, + openedFile, + setOpenedFile, + createFile, + updateFile, + deleteFile, + }; + + return {children}; +} + +export function useFiles(): FilesContextValue { + return useContext(FilesContext) as FilesContextValue; +} diff --git a/packages/web/src/components/apps/use-preview.tsx b/packages/web/src/components/apps/use-preview.tsx new file mode 100644 index 00000000..0281d46b --- /dev/null +++ b/packages/web/src/components/apps/use-preview.tsx @@ -0,0 +1,54 @@ +import React, { createContext, useContext, useEffect, useState } from 'react'; + +import { AppChannel } from '@/clients/websocket'; +import { PreviewStatusPayloadType } from '@srcbook/shared'; + +export type PreviewStatusType = 'connecting' | 'booting' | 'running' | 'stopped'; + +export interface PreviewContextValue { + url: string | null; + status: PreviewStatusType; + stop: () => void; + start: () => void; +} + +const PreviewContext = createContext(undefined); + +type ProviderPropsType = { + channel: AppChannel; + children: React.ReactNode; +}; + +export function PreviewProvider({ channel, children }: ProviderPropsType) { + const [url, setUrl] = useState(null); + const [status, setStatus] = useState('connecting'); + + useEffect(() => { + function onStatusUpdate(payload: PreviewStatusPayloadType) { + setUrl(payload.url); + setStatus(payload.status); + } + + channel.on('preview:status', onStatusUpdate); + + return () => channel.off('preview:status', onStatusUpdate); + }, [channel, setUrl, setStatus]); + + function start() { + channel.push('preview:start', {}); + } + + function stop() { + channel.push('preview:stop', {}); + } + + return ( + + {children} + + ); +} + +export function usePreview(): PreviewContextValue { + return useContext(PreviewContext) as PreviewContextValue; +} diff --git a/packages/web/src/components/apps/workspace/editor.tsx b/packages/web/src/components/apps/workspace/editor.tsx new file mode 100644 index 00000000..1804ad77 --- /dev/null +++ b/packages/web/src/components/apps/workspace/editor.tsx @@ -0,0 +1,81 @@ +import CodeMirror from '@uiw/react-codemirror'; +import { css } from '@codemirror/lang-css'; +import { html } from '@codemirror/lang-html'; +import { json } from '@codemirror/lang-json'; +import { javascript } from '@codemirror/lang-javascript'; +import { markdown } from '@codemirror/lang-markdown'; +import { cn } from '@/lib/utils'; +import useTheme from '@srcbook/components/src/components/use-theme'; +import { useFiles } from '../use-files'; +import { FileType } from '@srcbook/shared'; + +type PropsType = { + className?: string; +}; + +export function Editor(props: PropsType) { + const { openedFile, updateFile } = useFiles(); + + return ( +
+
+ {openedFile ? ( + + ) : ( +
+ Use the file explorer to open a file for editing +
+ )} +
+
+ ); +} + +function extname(path: string) { + return '.' + path.split('.').pop(); +} + +function CodeEditor({ + file, + onChange, +}: { + file: FileType; + onChange: (file: FileType, attrs: Partial) => void; +}) { + const { codeTheme } = useTheme(); + + const languageExtension = getCodeMirrorLanguageExtension(file); + const extensions = languageExtension ? [languageExtension] : []; + + return ( + onChange(file, { source })} + /> + ); +} + +function getCodeMirrorLanguageExtension(file: FileType) { + switch (extname(file.path)) { + case '.json': + return json(); + case '.css': + return css(); + case '.html': + return html(); + case '.md': + case '.markdown': + return markdown(); + case '.js': + case '.cjs': + case '.mjs': + case '.jsx': + case '.ts': + case '.cts': + case '.mts': + case '.tsx': + return javascript({ typescript: true, jsx: true }); + } +} diff --git a/packages/web/src/components/apps/workspace/preview.tsx b/packages/web/src/components/apps/workspace/preview.tsx new file mode 100644 index 00000000..4c2d4316 --- /dev/null +++ b/packages/web/src/components/apps/workspace/preview.tsx @@ -0,0 +1,20 @@ +import { cn } from '@/lib/utils'; +import { usePreview } from '../use-preview'; + +type PropsType = { + className?: string; +}; + +export function Preview(props: PropsType) { + const { url } = usePreview(); + + if (url === null) { + return; + } + + return ( +
+