From 2b0202aa84ae917c693eb53ba8664d1e54258086 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 29 Oct 2024 16:05:25 -0400 Subject: [PATCH 1/4] feat: add very basic proof of concept of starting up a srcbook on e2b --- packages/api/apps/e2b.mts | 267 +++++++++++++++++++++++++++ packages/api/package.json | 2 + packages/api/server/channels/app.mts | 94 +++------- pnpm-lock.yaml | 119 ++++++++++++ 4 files changed, 416 insertions(+), 66 deletions(-) create mode 100644 packages/api/apps/e2b.mts diff --git a/packages/api/apps/e2b.mts b/packages/api/apps/e2b.mts new file mode 100644 index 00000000..233a94d4 --- /dev/null +++ b/packages/api/apps/e2b.mts @@ -0,0 +1,267 @@ +import Path from 'node:path'; +import fs from 'node:fs/promises'; +import { glob } from 'glob'; +import { CommandHandle, CommandResult, Sandbox } from '@e2b/code-interpreter' +import { App } from '../db/schema.mjs'; +import { pathToApp } from './disk.mjs'; + +const SANDBOX_APP_CWD = '/app'; +const VITE_PORT_REGEX = /Local:.*http:\/\/localhost:([0-9]{1,4})/; + +type SandboxWithMetadata = +| { + status: 'booting'; + sandbox: Sandbox; + viteProcess: CommandHandle | null; + npmInstallProcess: CommandHandle | null; +} +| { + status: 'running'; + sandbox: Sandbox; + viteProcess: CommandHandle; + localPort: number; + url: string; + npmInstallProcess: CommandHandle | null; +} +| { status: 'stopping'; sandbox: Sandbox }; + +const sandboxes: Map = new Map(); + +export function getSandbox(appId: string): SandboxWithMetadata | null { + return sandboxes.get(appId) ?? null; +} + +export function updateSandbox(appId: string, literalOrUpdater: SandboxWithMetadata | ((old: SandboxWithMetadata) => SandboxWithMetadata)) { + const sandbox = sandboxes.get(appId); + if (sandbox) { + const newSandbox = typeof literalOrUpdater === "function" ? literalOrUpdater(sandbox) : literalOrUpdater; + sandboxes.set(appId, newSandbox); + } +} + +type CreateSandboxOptions = { + onStdout?: (encodedData: string) => void; + onStderr?: (encodedData: string) => void; + onExit?: (code: number) => void; + onRunning?: (url: string) => void; +}; + +export async function createSandbox(app: App, options?: CreateSandboxOptions): Promise { + // 1. start it + const sandboxWithMetadata: SandboxWithMetadata = { + status: 'booting', + sandbox: await Sandbox.create("eduzq2dmxgnpbzdwdbpc"), // (this id maps to a docker image) + viteProcess: null, + npmInstallProcess: null, + }; + sandboxes.set(app.externalId, sandboxWithMetadata); + + // 2. copy all the files in + const localCwd = pathToApp(app.externalId); + await recursiveCopyIntoSandbox(localCwd, sandboxWithMetadata.sandbox, SANDBOX_APP_CWD); + + // 2.5. run npm install + await runNpmInstallInSandbox(app.externalId); + + // 3. start vite + const onChangePort = async (newPort: number) => { + const url = `https://${sandboxWithMetadata.sandbox.getHost(newPort)}`; + + // NOTE: it takes a moment for the url to become active, in actuality there should maybe be a + // loop here that makes a request to `url` on an exponential backoff to see if its live yet? + await new Promise(r => setTimeout(r, 500)); + + updateSandbox(app.externalId, (old) => { + if (old.status === "stopping") { + return old; + } + if (old.viteProcess === null) { + return old; + } + + return { + status: "running", + sandbox: old.sandbox, + viteProcess: old.viteProcess, + localPort: newPort, + url, + npmInstallProcess: old.npmInstallProcess, + }; + }); + + if (options?.onRunning) { + options.onRunning(url); + } + }; + + const commandHandle = await sandboxWithMetadata.sandbox.commands.run( + Path.join(SANDBOX_APP_CWD, 'node_modules', '.bin', 'vite'), + { + background: true, + timeoutMs: 0, // (disables auto timeout behavior) + cwd: SANDBOX_APP_CWD, + onStdout(encodedData) { + console.log(encodedData); + + if (options?.onStdout) { + options.onStdout(encodedData); + } + + const potentialPortMatch = VITE_PORT_REGEX.exec(encodedData); + if (potentialPortMatch) { + const portString = potentialPortMatch[1]!; + const port = parseInt(portString, 10); + onChangePort(port); + } + }, + onStderr(encodedData) { + console.error(encodedData); + + if (options?.onStderr) { + options.onStderr(encodedData); + } + }, + } + ); + + updateSandbox(app.externalId, old => { + if (old.status === "stopping") { + return old; + } + return { + ...sandboxWithMetadata, + viteProcess: commandHandle, + }; + }); + + // 4. Wait for vite to complete + commandHandle.wait().then(result => { + console.error('e2b vite command completed! Deleting sandbox...', result); + sandboxes.delete(app.externalId); + + if (options?.onExit) { + options.onExit(result.exitCode); + } + }).catch(err => { + console.error('Error waiting for e2b vite command to complete:', err); + }); + + return sandboxWithMetadata; +} + +export async function terminateSandbox(appId: string) { + const sandboxWithMetadata = sandboxes.get(appId); + if (!sandboxWithMetadata) { + return; + } + if (sandboxWithMetadata.status !== "running") { + return; + } + + updateSandbox(appId, (old) => { + if (old.status !== "running") { + return old; + } + + return { + status: "stopping", + sandbox: old.sandbox, + }; + }); + + await sandboxWithMetadata.sandbox.kill(); + sandboxes.delete(appId); +} + +type NpmInstallOptions = { + onStdout?: (encodedData: string) => void; + onStderr?: (encodedData: string) => void; + onExit?: (code: number) => void; +} + +export async function runNpmInstallInSandbox(appId: string, options?: NpmInstallOptions): Promise { + const sandboxWithMetadata = getSandbox(appId); + if (!sandboxWithMetadata) { + return null; + } + if (sandboxWithMetadata.status === "stopping") { + return null; + } + if (sandboxWithMetadata.npmInstallProcess !== null) { + console.warn(`Warning: attempted to start a second npm insatll process in sandbox for app ${appId}!`); + return null; + } + + const commandHandle = await sandboxWithMetadata.sandbox.commands.run( + "npm install --include=dev", + { + background: true, + cwd: SANDBOX_APP_CWD, + onStdout(encodedData) { + console.log(encodedData); + + if (options?.onStdout) { + options.onStdout(encodedData); + } + }, + onStderr(encodedData) { + console.error(encodedData); + + if (options?.onStderr) { + options.onStderr(encodedData); + } + }, + } + ); + updateSandbox(appId, old => { + if (old.status !== "running") { + return old; + } + return { + ...sandboxWithMetadata, + npmInstallProcess: commandHandle, + }; + }); + + // 4. Wait for vite to complete + commandHandle.wait().then(result => { + console.error('e2b npm install completed!', result); + updateSandbox(appId, old => { + if (old.status !== "running") { + return old; + } + return { + ...sandboxWithMetadata, + npmInstallProcess: null, + }; + }); + + if (options?.onExit) { + options.onExit(result.exitCode); + } + }).catch(err => { + console.error('Error waiting for e2b vite command to complete:', err); + }); + + return commandHandle.wait(); +} + +// NOTE: there's not a built in way to do this yet: +// https://e2b.dev/docs/quickstart/upload-download-files +async function recursiveCopyIntoSandbox(fromDirectory: string, sandbox: Sandbox, toDirectoryInSandbox: string) { + const fileList = await glob(Path.join(fromDirectory, "**"), { + ignore: Path.join(fromDirectory, "node_modules", "**"), + }); + for (const filePath of fileList) { + const stat = await fs.stat(filePath); + if (!stat.isFile()) { + continue; + } + + const fileContents = await fs.readFile(filePath); + const relativeFilePath = Path.relative(fromDirectory, filePath) + const filePathInSandbox = Path.join(toDirectoryInSandbox, relativeFilePath); + console.log('copying', filePath, '=>', filePathInSandbox); + await sandbox.files.write(filePathInSandbox, fileContents); + } +} diff --git a/packages/api/package.json b/packages/api/package.json index 75c6bbff..d3eabfc7 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -24,6 +24,7 @@ "dependencies": { "@ai-sdk/anthropic": "catalog:", "@ai-sdk/openai": "catalog:", + "@e2b/code-interpreter": "^1.0.3", "@srcbook/shared": "workspace:^", "ai": "^3.3.33", "archiver": "^7.0.1", @@ -33,6 +34,7 @@ "drizzle-orm": "^0.33.0", "express": "^4.20.0", "fast-xml-parser": "^4.5.0", + "glob": "^11.0.0", "marked": "catalog:", "posthog-node": "^4.2.0", "simple-git": "^3.27.0", diff --git a/packages/api/server/channels/app.mts b/packages/api/server/channels/app.mts index 1d860270..1161e4aa 100644 --- a/packages/api/server/channels/app.mts +++ b/packages/api/server/channels/app.mts @@ -23,13 +23,9 @@ import { fileUpdated, pathToApp } from '../../apps/disk.mjs'; import { directoryExists } from '../../fs-utils.mjs'; import { getAppProcess, - setAppProcess, - deleteAppProcess, npmInstall, - viteServer, } from '../../apps/processes.mjs'; - -const VITE_PORT_REGEX = /Local:.*http:\/\/localhost:([0-9]{1,4})/; +import { createSandbox, getSandbox, terminateSandbox } from "../../apps/e2b.mjs"; type AppContextType = MessageContextType<'appId'>; @@ -44,12 +40,12 @@ async function previewStart( return; } - const existingProcess = getAppProcess(app.externalId, 'vite:server'); - - if (existingProcess) { + const existingSandbox = getSandbox(app.externalId); + console.log('EXISTING', existingSandbox); + if (existingSandbox) { wss.broadcast(`app:${app.externalId}`, 'preview:status', { - status: 'running', - url: `http://localhost:${existingProcess.port}/`, + status: existingSandbox.status, + url: existingSandbox.status === "running" ? existingSandbox.url : null, }); return; } @@ -59,51 +55,16 @@ async function previewStart( status: 'booting', }); - const onChangePort = (newPort: number) => { - const process = getAppProcess(app.externalId, 'vite:server'); - - // This is not expected to happen - if (!process) { - wss.broadcast(`app:${app.externalId}`, 'preview:status', { - url: null, - status: 'stopped', - code: null, - }); - return; - } - - setAppProcess(app.externalId, { ...process, port: newPort }); - - wss.broadcast(`app:${app.externalId}`, 'preview:status', { - url: `http://localhost:${newPort}/`, - status: 'running', - }); - }; - - viteServer(app.externalId, { - args: [], - stdout: (data) => { - const encodedData = data.toString('utf8'); - console.log(encodedData); - + await createSandbox(app, { + onStdout: (encodedData: string) => { wss.broadcast(`app:${app.externalId}`, 'preview:log', { log: { type: 'stdout', data: encodedData, }, }); - - const potentialPortMatch = VITE_PORT_REGEX.exec(encodedData); - if (potentialPortMatch) { - const portString = potentialPortMatch[1]!; - const port = parseInt(portString, 10); - onChangePort(port); - } }, - stderr: (data) => { - const encodedData = data.toString('utf8'); - console.error(encodedData); - + onStderr: (encodedData: string) => { wss.broadcast(`app:${app.externalId}`, 'preview:log', { log: { type: 'stderr', @@ -111,26 +72,19 @@ async function previewStart( }, }); }, - onExit: (code) => { - deleteAppProcess(app.externalId, 'vite:server'); - + onExit: (code: number) => { wss.broadcast(`app:${app.externalId}`, 'preview:status', { url: null, status: 'stopped', code: code, }); }, - onError: (_error) => { - // Errors happen when we try to run vite before node modules are installed. - // Make sure we clean up the app process and inform the client. - deleteAppProcess(app.externalId, 'vite:server'); + onRunning: (url: string) => { + console.log('RUNNING:', url) - // TODO: Use a different event to communicate to the client there was an error. - // If the error is ENOENT, for example, it means node_modules and/or vite is missing. wss.broadcast(`app:${app.externalId}`, 'preview:status', { - url: null, - status: 'stopped', - code: null, + status: 'running', + url, }); }, }); @@ -147,9 +101,9 @@ async function previewStop( return; } - const result = getAppProcess(app.externalId, 'vite:server'); + const sandboxWithMetadata = getSandbox(app.externalId); - if (!result) { + if (!sandboxWithMetadata) { conn.reply(`app:${app.externalId}`, 'preview:status', { url: null, status: 'stopped', @@ -158,10 +112,18 @@ async function previewStop( return; } - // Killing the process should result in its onExit handler being called. - // The onExit handler will remove the process from the processMetadata map - // and send the `preview:status` event with a value of 'stopped' - result.process.kill('SIGTERM'); + // // Killing the process should result in its onExit handler being called. + // // The onExit handler will remove the process from the processMetadata map + // // and send the `preview:status` event with a value of 'stopped' + // result.process.kill('SIGTERM'); + console.log('TERMINATE!', app.externalId); + + await terminateSandbox(app.externalId); + conn.reply(`app:${app.externalId}`, 'preview:status', { + url: null, + status: 'stopped', + code: null, + }); } async function dependenciesInstall(payload: DepsInstallPayloadType, context: AppContextType) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 14d0e126..50a64c24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,6 +57,9 @@ importers: '@ai-sdk/openai': specifier: 'catalog:' version: 0.0.58(zod@3.23.8) + '@e2b/code-interpreter': + specifier: ^1.0.3 + version: 1.0.3 '@srcbook/shared': specifier: workspace:^ version: link:../shared @@ -84,6 +87,9 @@ importers: fast-xml-parser: specifier: ^4.5.0 version: 4.5.0 + glob: + specifier: ^11.0.0 + version: 11.0.0 marked: specifier: 'catalog:' version: 14.1.2 @@ -611,6 +617,9 @@ packages: '@braintree/sanitize-url@7.1.0': resolution: {integrity: sha512-o+UlMLt49RvtCASlOMW0AkHnabN9wR9rwCCherxO0yG4Npy34GkvrAqdXQvrhNs+jh+gkK8gB8Lf05qL/O7KWg==} + '@bufbuild/protobuf@1.10.0': + resolution: {integrity: sha512-QDdVFLoN93Zjg36NoQPZfsVH9tZew7wKDKyV5qRdj8ntT4wQCOradQjRaTdwMhWUYsgKsvCINKKm87FdEk96Ag==} + '@changesets/apply-release-plan@7.0.5': resolution: {integrity: sha512-1cWCk+ZshEkSVEZrm2fSj1Gz8sYvxgUL4Q78+1ZZqeqfuevPTPk033/yUZ3df8BKMohkqqHfzj0HOOrG0KtXTw==} @@ -728,9 +737,24 @@ packages: '@codemirror/view@6.33.0': resolution: {integrity: sha512-AroaR3BvnjRW8fiZBalAaK+ZzB5usGgI014YKElYZvQdNH5ZIidHlO+cyf/2rWzyBFRkvG6VhiXeAEbC53P2YQ==} + '@connectrpc/connect-web@1.6.1': + resolution: {integrity: sha512-GVfxQOmt3TtgTaKeXLS/EA2IHa3nHxwe2BCHT7X0Q/0hohM+nP5DDnIItGEjGrGdt3LTTqWqE4s70N4h+qIMlQ==} + peerDependencies: + '@bufbuild/protobuf': ^1.10.0 + '@connectrpc/connect': 1.6.1 + + '@connectrpc/connect@1.6.1': + resolution: {integrity: sha512-KchMDNtU4CDTdkyf0qG7ugJ6qHTOR/aI7XebYn3OTCNagaDYWiZUVKgRgwH79yeMkpNgvEUaXSK7wKjaBK9b/Q==} + peerDependencies: + '@bufbuild/protobuf': ^1.10.0 + '@drizzle-team/brocli@0.10.1': resolution: {integrity: sha512-AHy0vjc+n/4w/8Mif+w86qpppHuF3AyXbcWW+R/W7GNA3F5/p2nuhlkCJaTXSLZheB4l1rtHzOfr9A7NwoR/Zg==} + '@e2b/code-interpreter@1.0.3': + resolution: {integrity: sha512-/dfMagUEytQtwqkab+0glMPCpPvOwhGaUZvOscT/YvxJxaYPswwjIWb3TXa9DeV25XYw//72syT2wo6rGnnCKw==} + engines: {node: '>=18'} + '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} deprecated: 'Merged into tsx: https://tsx.is' @@ -2719,6 +2743,9 @@ packages: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} + compare-versions@6.1.1: + resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} + compress-commons@6.0.2: resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} engines: {node: '>= 14'} @@ -3188,6 +3215,10 @@ packages: sqlite3: optional: true + e2b@1.0.3: + resolution: {integrity: sha512-NlawX9sc8kbiy8BDfPL+Zq6kLW+wK8v3hfmEEa7SKjoE7/DI5K/iIxCMV5L39Ua9kSBsrAf6YLz2LF53g8Wwcg==} + engines: {node: '>=18'} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -3613,6 +3644,11 @@ packages: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} hasBin: true + glob@11.0.0: + resolution: {integrity: sha512-9UiX/Bl6J2yaBbxKoEBRm4Cipxgok8kQYcOPEhScPwebu2I0HoQOuYdIO6S3hLuWoZgpDpwQZMzTFxgpkyT76g==} + engines: {node: 20 || >=22} + hasBin: true + glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Glob versions prior to v9 are no longer supported @@ -3916,6 +3952,10 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jackspeak@4.0.2: + resolution: {integrity: sha512-bZsjR/iRjl1Nk1UkjGpAzLNfQtzuijhn2g+pbZb98HQ1Gk8vM9hfbxeMBP+M2/UUdwj0RqGG3mlvk2MsAqwvEw==} + engines: {node: 20 || >=22} + jiti@1.21.6: resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==} hasBin: true @@ -4054,6 +4094,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.0.1: + resolution: {integrity: sha512-CgeuL5uom6j/ZVrg7G/+1IXqRY8JXX4Hghfy5YE0EhoYQWvndP1kufu58cmZLNIDKnRhZrXfdS9urVWx98AipQ==} + engines: {node: 20 || >=22} + lru-cache@4.1.5: resolution: {integrity: sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==} @@ -4134,6 +4178,10 @@ packages: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} + minimatch@10.0.1: + resolution: {integrity: sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==} + engines: {node: 20 || >=22} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -4282,6 +4330,12 @@ packages: resolution: {integrity: sha512-IyQLYKGYoEEkUCEm2frPzwHDJ3Ym663KtivnY6pWCzuoi6/HgSIMMxpcuTRS81GH6tiULPYGmTxIvzXdmPIWOw==} hasBin: true + openapi-fetch@0.9.8: + resolution: {integrity: sha512-zM6elH0EZStD/gSiNlcPrzXcVQ/pZo3BDvC6CDwRDUt1dDzxlshpmQnpD6cZaJ39THaSmwVCxxRrPKNM1hHrDg==} + + openapi-typescript-helpers@0.0.8: + resolution: {integrity: sha512-1eNjQtbfNi5Z/kFhagDIaIRj6qqDzhjNJKz8cmMW0CVdGwT6e1GLbAfgI0d28VTJa1A8jz82jm/4dG8qNoNS8g==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -4369,6 +4423,10 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.0: + resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==} + engines: {node: 20 || >=22} + path-to-regexp@0.1.10: resolution: {integrity: sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==} @@ -4408,6 +4466,9 @@ packages: pkg-types@1.2.0: resolution: {integrity: sha512-+ifYuSSqOQ8CqP4MbZA5hDpb97n3E8SVWdJe+Wms9kj745lmd3b7EZJiqvmLwAlmRfjrI7Hi5z3kdBJ93lFNPA==} + platform@1.3.6: + resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} + please-upgrade-node@3.2.0: resolution: {integrity: sha512-gQR3WpIgNIKwBMVLkpMUeR3e1/E1y42bqDQZfql+kDeXd8COYfM8PQA4X6y7a8u9Ua9FHmsrrmirW2vHs45hWg==} @@ -5597,6 +5658,8 @@ snapshots: '@braintree/sanitize-url@7.1.0': {} + '@bufbuild/protobuf@1.10.0': {} + '@changesets/apply-release-plan@7.0.5': dependencies: '@changesets/config': 3.0.3 @@ -5863,8 +5926,21 @@ snapshots: style-mod: 4.1.2 w3c-keyname: 2.2.8 + '@connectrpc/connect-web@1.6.1(@bufbuild/protobuf@1.10.0)(@connectrpc/connect@1.6.1(@bufbuild/protobuf@1.10.0))': + dependencies: + '@bufbuild/protobuf': 1.10.0 + '@connectrpc/connect': 1.6.1(@bufbuild/protobuf@1.10.0) + + '@connectrpc/connect@1.6.1(@bufbuild/protobuf@1.10.0)': + dependencies: + '@bufbuild/protobuf': 1.10.0 + '@drizzle-team/brocli@0.10.1': {} + '@e2b/code-interpreter@1.0.3': + dependencies: + e2b: 1.0.3 + '@esbuild-kit/core-utils@3.3.2': dependencies: esbuild: 0.18.20 @@ -7813,6 +7889,8 @@ snapshots: commander@8.3.0: {} + compare-versions@6.1.1: {} + compress-commons@6.0.2: dependencies: crc-32: 1.2.2 @@ -8243,6 +8321,15 @@ snapshots: better-sqlite3: 11.3.0 react: 18.3.1 + e2b@1.0.3: + dependencies: + '@bufbuild/protobuf': 1.10.0 + '@connectrpc/connect': 1.6.1(@bufbuild/protobuf@1.10.0) + '@connectrpc/connect-web': 1.6.1(@bufbuild/protobuf@1.10.0)(@connectrpc/connect@1.6.1(@bufbuild/protobuf@1.10.0)) + compare-versions: 6.1.1 + openapi-fetch: 0.9.8 + platform: 1.3.6 + eastasianwidth@0.2.0: {} ee-first@1.1.1: {} @@ -8861,6 +8948,15 @@ snapshots: package-json-from-dist: 1.0.0 path-scurry: 1.11.1 + glob@11.0.0: + dependencies: + foreground-child: 3.3.0 + jackspeak: 4.0.2 + minimatch: 10.0.1 + minipass: 7.1.2 + package-json-from-dist: 1.0.0 + path-scurry: 2.0.0 + glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -9150,6 +9246,10 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jackspeak@4.0.2: + dependencies: + '@isaacs/cliui': 8.0.2 + jiti@1.21.6: {} js-tokens@4.0.0: {} @@ -9272,6 +9372,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.0.1: {} + lru-cache@4.1.5: dependencies: pseudomap: 1.0.2 @@ -9349,6 +9451,10 @@ snapshots: mimic-response@3.1.0: {} + minimatch@10.0.1: + dependencies: + brace-expansion: 2.0.1 + minimatch@3.1.2: dependencies: brace-expansion: 1.1.11 @@ -9496,6 +9602,12 @@ snapshots: - encoding optional: true + openapi-fetch@0.9.8: + dependencies: + openapi-typescript-helpers: 0.0.8 + + openapi-typescript-helpers@0.0.8: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -9569,6 +9681,11 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 + path-scurry@2.0.0: + dependencies: + lru-cache: 11.0.1 + minipass: 7.1.2 + path-to-regexp@0.1.10: {} path-type@4.0.0: {} @@ -9599,6 +9716,8 @@ snapshots: mlly: 1.7.1 pathe: 1.1.2 + platform@1.3.6: {} + please-upgrade-node@3.2.0: dependencies: semver-compare: 1.0.0 From 61655511bfd75a02459c6cd11c81fcc86738c3ff Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 29 Oct 2024 16:28:09 -0400 Subject: [PATCH 2/4] feat: stream writes from srcbook to e2b --- packages/api/apps/e2b.mts | 12 ++++++++++++ packages/api/server/channels/app.mts | 5 ++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/api/apps/e2b.mts b/packages/api/apps/e2b.mts index 233a94d4..6783eb05 100644 --- a/packages/api/apps/e2b.mts +++ b/packages/api/apps/e2b.mts @@ -2,6 +2,8 @@ import Path from 'node:path'; import fs from 'node:fs/promises'; import { glob } from 'glob'; import { CommandHandle, CommandResult, Sandbox } from '@e2b/code-interpreter' +import { FileType } from '@srcbook/shared'; + import { App } from '../db/schema.mjs'; import { pathToApp } from './disk.mjs'; @@ -265,3 +267,13 @@ async function recursiveCopyIntoSandbox(fromDirectory: string, sandbox: Sandbox, await sandbox.files.write(filePathInSandbox, fileContents); } } + +export async function writeFileToSandbox(app: App, file: FileType) { + const sandbox = getSandbox(app.externalId); + if (!sandbox) { + return; + } + + await sandbox.sandbox.files.write(Path.join(SANDBOX_APP_CWD, file.path), file.source); + // NOTE: broadcastFileUpdated may be needed here? +} diff --git a/packages/api/server/channels/app.mts b/packages/api/server/channels/app.mts index 1161e4aa..dea8e6f0 100644 --- a/packages/api/server/channels/app.mts +++ b/packages/api/server/channels/app.mts @@ -25,7 +25,7 @@ import { getAppProcess, npmInstall, } from '../../apps/processes.mjs'; -import { createSandbox, getSandbox, terminateSandbox } from "../../apps/e2b.mjs"; +import { createSandbox, getSandbox, terminateSandbox, writeFileToSandbox } from "../../apps/e2b.mjs"; type AppContextType = MessageContextType<'appId'>; @@ -184,6 +184,9 @@ async function onFileUpdated(payload: FileUpdatedPayloadType, context: AppContex } fileUpdated(app, payload.file as FileType); + + console.log('WRITE'); + await writeFileToSandbox(app, payload.file); } export function register(wss: WebSocketServer) { From b24c4f50b6685d782ce0bea1d4be50a3805d2056 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 29 Oct 2024 16:30:12 -0400 Subject: [PATCH 3/4] fix: run npm run format --- packages/api/apps/e2b.mts | 156 +++++++++++++++------------ packages/api/server/channels/app.mts | 14 +-- 2 files changed, 97 insertions(+), 73 deletions(-) diff --git a/packages/api/apps/e2b.mts b/packages/api/apps/e2b.mts index 6783eb05..93671b3b 100644 --- a/packages/api/apps/e2b.mts +++ b/packages/api/apps/e2b.mts @@ -1,7 +1,7 @@ import Path from 'node:path'; import fs from 'node:fs/promises'; import { glob } from 'glob'; -import { CommandHandle, CommandResult, Sandbox } from '@e2b/code-interpreter' +import { CommandHandle, CommandResult, Sandbox } from '@e2b/code-interpreter'; import { FileType } from '@srcbook/shared'; import { App } from '../db/schema.mjs'; @@ -11,21 +11,21 @@ const SANDBOX_APP_CWD = '/app'; const VITE_PORT_REGEX = /Local:.*http:\/\/localhost:([0-9]{1,4})/; type SandboxWithMetadata = -| { - status: 'booting'; - sandbox: Sandbox; - viteProcess: CommandHandle | null; - npmInstallProcess: CommandHandle | null; -} -| { - status: 'running'; - sandbox: Sandbox; - viteProcess: CommandHandle; - localPort: number; - url: string; - npmInstallProcess: CommandHandle | null; -} -| { status: 'stopping'; sandbox: Sandbox }; + | { + status: 'booting'; + sandbox: Sandbox; + viteProcess: CommandHandle | null; + npmInstallProcess: CommandHandle | null; + } + | { + status: 'running'; + sandbox: Sandbox; + viteProcess: CommandHandle; + localPort: number; + url: string; + npmInstallProcess: CommandHandle | null; + } + | { status: 'stopping'; sandbox: Sandbox }; const sandboxes: Map = new Map(); @@ -33,10 +33,14 @@ export function getSandbox(appId: string): SandboxWithMetadata | null { return sandboxes.get(appId) ?? null; } -export function updateSandbox(appId: string, literalOrUpdater: SandboxWithMetadata | ((old: SandboxWithMetadata) => SandboxWithMetadata)) { +export function updateSandbox( + appId: string, + literalOrUpdater: SandboxWithMetadata | ((old: SandboxWithMetadata) => SandboxWithMetadata), +) { const sandbox = sandboxes.get(appId); if (sandbox) { - const newSandbox = typeof literalOrUpdater === "function" ? literalOrUpdater(sandbox) : literalOrUpdater; + const newSandbox = + typeof literalOrUpdater === 'function' ? literalOrUpdater(sandbox) : literalOrUpdater; sandboxes.set(appId, newSandbox); } } @@ -48,11 +52,14 @@ type CreateSandboxOptions = { onRunning?: (url: string) => void; }; -export async function createSandbox(app: App, options?: CreateSandboxOptions): Promise { +export async function createSandbox( + app: App, + options?: CreateSandboxOptions, +): Promise { // 1. start it const sandboxWithMetadata: SandboxWithMetadata = { status: 'booting', - sandbox: await Sandbox.create("eduzq2dmxgnpbzdwdbpc"), // (this id maps to a docker image) + sandbox: await Sandbox.create('eduzq2dmxgnpbzdwdbpc'), // (this id maps to a docker image) viteProcess: null, npmInstallProcess: null, }; @@ -71,10 +78,10 @@ export async function createSandbox(app: App, options?: CreateSandboxOptions): P // NOTE: it takes a moment for the url to become active, in actuality there should maybe be a // loop here that makes a request to `url` on an exponential backoff to see if its live yet? - await new Promise(r => setTimeout(r, 500)); + await new Promise((r) => setTimeout(r, 500)); updateSandbox(app.externalId, (old) => { - if (old.status === "stopping") { + if (old.status === 'stopping') { return old; } if (old.viteProcess === null) { @@ -82,7 +89,7 @@ export async function createSandbox(app: App, options?: CreateSandboxOptions): P } return { - status: "running", + status: 'running', sandbox: old.sandbox, viteProcess: old.viteProcess, localPort: newPort, @@ -123,11 +130,11 @@ export async function createSandbox(app: App, options?: CreateSandboxOptions): P options.onStderr(encodedData); } }, - } + }, ); - updateSandbox(app.externalId, old => { - if (old.status === "stopping") { + updateSandbox(app.externalId, (old) => { + if (old.status === 'stopping') { return old; } return { @@ -137,16 +144,19 @@ export async function createSandbox(app: App, options?: CreateSandboxOptions): P }); // 4. Wait for vite to complete - commandHandle.wait().then(result => { - console.error('e2b vite command completed! Deleting sandbox...', result); - sandboxes.delete(app.externalId); - - if (options?.onExit) { - options.onExit(result.exitCode); - } - }).catch(err => { - console.error('Error waiting for e2b vite command to complete:', err); - }); + commandHandle + .wait() + .then((result) => { + console.error('e2b vite command completed! Deleting sandbox...', result); + sandboxes.delete(app.externalId); + + if (options?.onExit) { + options.onExit(result.exitCode); + } + }) + .catch((err) => { + console.error('Error waiting for e2b vite command to complete:', err); + }); return sandboxWithMetadata; } @@ -156,17 +166,17 @@ export async function terminateSandbox(appId: string) { if (!sandboxWithMetadata) { return; } - if (sandboxWithMetadata.status !== "running") { + if (sandboxWithMetadata.status !== 'running') { return; } updateSandbox(appId, (old) => { - if (old.status !== "running") { + if (old.status !== 'running') { return old; } return { - status: "stopping", + status: 'stopping', sandbox: old.sandbox, }; }); @@ -179,23 +189,28 @@ type NpmInstallOptions = { onStdout?: (encodedData: string) => void; onStderr?: (encodedData: string) => void; onExit?: (code: number) => void; -} +}; -export async function runNpmInstallInSandbox(appId: string, options?: NpmInstallOptions): Promise { +export async function runNpmInstallInSandbox( + appId: string, + options?: NpmInstallOptions, +): Promise { const sandboxWithMetadata = getSandbox(appId); if (!sandboxWithMetadata) { return null; } - if (sandboxWithMetadata.status === "stopping") { + if (sandboxWithMetadata.status === 'stopping') { return null; } if (sandboxWithMetadata.npmInstallProcess !== null) { - console.warn(`Warning: attempted to start a second npm insatll process in sandbox for app ${appId}!`); + console.warn( + `Warning: attempted to start a second npm insatll process in sandbox for app ${appId}!`, + ); return null; } const commandHandle = await sandboxWithMetadata.sandbox.commands.run( - "npm install --include=dev", + 'npm install --include=dev', { background: true, cwd: SANDBOX_APP_CWD, @@ -213,10 +228,10 @@ export async function runNpmInstallInSandbox(appId: string, options?: NpmInstall options.onStderr(encodedData); } }, - } + }, ); - updateSandbox(appId, old => { - if (old.status !== "running") { + updateSandbox(appId, (old) => { + if (old.status !== 'running') { return old; } return { @@ -226,33 +241,40 @@ export async function runNpmInstallInSandbox(appId: string, options?: NpmInstall }); // 4. Wait for vite to complete - commandHandle.wait().then(result => { - console.error('e2b npm install completed!', result); - updateSandbox(appId, old => { - if (old.status !== "running") { - return old; + commandHandle + .wait() + .then((result) => { + console.error('e2b npm install completed!', result); + updateSandbox(appId, (old) => { + if (old.status !== 'running') { + return old; + } + return { + ...sandboxWithMetadata, + npmInstallProcess: null, + }; + }); + + if (options?.onExit) { + options.onExit(result.exitCode); } - return { - ...sandboxWithMetadata, - npmInstallProcess: null, - }; + }) + .catch((err) => { + console.error('Error waiting for e2b vite command to complete:', err); }); - if (options?.onExit) { - options.onExit(result.exitCode); - } - }).catch(err => { - console.error('Error waiting for e2b vite command to complete:', err); - }); - return commandHandle.wait(); } // NOTE: there's not a built in way to do this yet: // https://e2b.dev/docs/quickstart/upload-download-files -async function recursiveCopyIntoSandbox(fromDirectory: string, sandbox: Sandbox, toDirectoryInSandbox: string) { - const fileList = await glob(Path.join(fromDirectory, "**"), { - ignore: Path.join(fromDirectory, "node_modules", "**"), +async function recursiveCopyIntoSandbox( + fromDirectory: string, + sandbox: Sandbox, + toDirectoryInSandbox: string, +) { + const fileList = await glob(Path.join(fromDirectory, '**'), { + ignore: Path.join(fromDirectory, 'node_modules', '**'), }); for (const filePath of fileList) { const stat = await fs.stat(filePath); @@ -261,7 +283,7 @@ async function recursiveCopyIntoSandbox(fromDirectory: string, sandbox: Sandbox, } const fileContents = await fs.readFile(filePath); - const relativeFilePath = Path.relative(fromDirectory, filePath) + const relativeFilePath = Path.relative(fromDirectory, filePath); const filePathInSandbox = Path.join(toDirectoryInSandbox, relativeFilePath); console.log('copying', filePath, '=>', filePathInSandbox); await sandbox.files.write(filePathInSandbox, fileContents); diff --git a/packages/api/server/channels/app.mts b/packages/api/server/channels/app.mts index dea8e6f0..f37646b2 100644 --- a/packages/api/server/channels/app.mts +++ b/packages/api/server/channels/app.mts @@ -21,11 +21,13 @@ import WebSocketServer, { import { loadApp } from '../../apps/app.mjs'; import { fileUpdated, pathToApp } from '../../apps/disk.mjs'; import { directoryExists } from '../../fs-utils.mjs'; +import { getAppProcess, npmInstall } from '../../apps/processes.mjs'; import { - getAppProcess, - npmInstall, -} from '../../apps/processes.mjs'; -import { createSandbox, getSandbox, terminateSandbox, writeFileToSandbox } from "../../apps/e2b.mjs"; + createSandbox, + getSandbox, + terminateSandbox, + writeFileToSandbox, +} from '../../apps/e2b.mjs'; type AppContextType = MessageContextType<'appId'>; @@ -45,7 +47,7 @@ async function previewStart( if (existingSandbox) { wss.broadcast(`app:${app.externalId}`, 'preview:status', { status: existingSandbox.status, - url: existingSandbox.status === "running" ? existingSandbox.url : null, + url: existingSandbox.status === 'running' ? existingSandbox.url : null, }); return; } @@ -80,7 +82,7 @@ async function previewStart( }); }, onRunning: (url: string) => { - console.log('RUNNING:', url) + console.log('RUNNING:', url); wss.broadcast(`app:${app.externalId}`, 'preview:status', { status: 'running', From b7440dcd55ed7de6ffab42bea10c61a405f59d9f Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 29 Oct 2024 16:42:34 -0400 Subject: [PATCH 4/4] feat: add e2b dockerfile --- e2b.Dockerfile | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 e2b.Dockerfile diff --git a/e2b.Dockerfile b/e2b.Dockerfile new file mode 100644 index 00000000..4aee6033 --- /dev/null +++ b/e2b.Dockerfile @@ -0,0 +1,4 @@ +# You can use most Debian-based base images +FROM node:22 + +# Install dependencies and customize sandbox