diff --git a/.env.example b/.env.example index 7b42de6..8f3275c 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,10 @@ SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key ### Visit: https://github.com/e2b-dev/infra/blob/main/README.md for a self-hosting guide INFRA_API_URL=https://api.e2b.dev +### Default domain for the E2B SDK +### Used for Sandbox Details Page +E2B_DOMAIN=e2b.dev + ### KV database configuration KV_REST_API_TOKEN= KV_REST_API_URL= diff --git a/bun.lock b/bun.lock index 0a79556..b03579a 100644 --- a/bun.lock +++ b/bun.lock @@ -4,11 +4,6 @@ "": { "name": "@e2b/dashboard", "dependencies": { - "@bufbuild/buf": "^1.54.0", - "@bufbuild/protobuf": "^2.5.2", - "@bufbuild/protoc-gen-es": "^2.5.2", - "@connectrpc/connect": "^2.0.2", - "@connectrpc/protoc-gen-connect-es": "^1.6.1", "@fumadocs/mdx-remote": "^1.2.0", "@google-cloud/storage": "^7.15.2", "@hookform/resolvers": "^3.10.0", @@ -49,6 +44,7 @@ "clsx": "^2.1.1", "cmdk": "^1.0.4", "date-fns": "^4.1.0", + "e2b": "^1.5.3", "fast-xml-parser": "^4.5.1", "fumadocs-core": "^15.0.6", "fumadocs-mdx": "^11.5.3", @@ -357,28 +353,8 @@ "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.1", "", {}, "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw=="], - "@bufbuild/buf": ["@bufbuild/buf@1.54.0", "", { "optionalDependencies": { "@bufbuild/buf-darwin-arm64": "1.54.0", "@bufbuild/buf-darwin-x64": "1.54.0", "@bufbuild/buf-linux-aarch64": "1.54.0", "@bufbuild/buf-linux-armv7": "1.54.0", "@bufbuild/buf-linux-x64": "1.54.0", "@bufbuild/buf-win32-arm64": "1.54.0", "@bufbuild/buf-win32-x64": "1.54.0" }, "bin": { "buf": "bin/buf", "protoc-gen-buf-breaking": "bin/protoc-gen-buf-breaking", "protoc-gen-buf-lint": "bin/protoc-gen-buf-lint" } }, "sha512-UkjZmVslA7YAxhUQVxE2O4HX4qD7aMspjkuG3vsjnvmAkiV6Jhz47z3focCuPI28e59H20TiQNhc9Y3fkffWPw=="], - - "@bufbuild/buf-darwin-arm64": ["@bufbuild/buf-darwin-arm64@1.54.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MkwlxcuHH8YO2wyQ2nGAv5SwBRCR4PtA8zcQb7AR6q93Cgy314ac8blGjfpenprjI3kAAhxc9BQK4t+/hkIS/A=="], - - "@bufbuild/buf-darwin-x64": ["@bufbuild/buf-darwin-x64@1.54.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-59Z+6BxvVwBbcpLOAwD8TLobngb9YUvUZ1nnP1IyIJnay/tIY+yfmgAdgMwm3VUZlbaFlURGmD34UAwEsxodGQ=="], - - "@bufbuild/buf-linux-aarch64": ["@bufbuild/buf-linux-aarch64@1.54.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-cUbvujfoAQGsnRH/+UfKxt0Hfe6PGHjM/gLiC2Kgv8fcoIWjPJMBBgdl/TLbq1QrVcCXSvMc16hW5ias7Jdyfw=="], - - "@bufbuild/buf-linux-armv7": ["@bufbuild/buf-linux-armv7@1.54.0", "", { "os": "linux", "cpu": "arm" }, "sha512-xdKjzPsOo6E2eth3uGIRoVG9TpPVHOUucr0MeCRVhM2hb5gbM8KQLn6iDxVGbQFq6eL2qe+B0b8k9HfuwzirWA=="], - - "@bufbuild/buf-linux-x64": ["@bufbuild/buf-linux-x64@1.54.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ZnfaE5GLAhyvR/ponDgG+s6FbtMEm+RaS2f0EoBLORYC7sK/Elfmw2Q0XcjHyEl83u4hELCqej9T0eUxbgxtow=="], - - "@bufbuild/buf-win32-arm64": ["@bufbuild/buf-win32-arm64@1.54.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-N5YlX8c6p+KZIWYmx03viYF/FLuY5GyzHgor17nuJUYhF1xFyIJL8v4mhqcQ8Pq0xua9IyRwmSxHJKyrdNatcg=="], - - "@bufbuild/buf-win32-x64": ["@bufbuild/buf-win32-x64@1.54.0", "", { "os": "win32", "cpu": "x64" }, "sha512-PepTA9RcLCjukQhFPFBqKXF9mVwct+ZSBeuLjFuUVcHovdGUZXspNTb5LnuIDjWXx2fcALs0xb/FNUNd6pNjbA=="], - "@bufbuild/protobuf": ["@bufbuild/protobuf@2.5.2", "", {}, "sha512-foZ7qr0IsUBjzWIq+SuBLfdQCpJ1j8cTuNNT4owngTHoN5KsJb8L9t65fzz7SCeSWzescoOil/0ldqiL041ABg=="], - "@bufbuild/protoc-gen-es": ["@bufbuild/protoc-gen-es@2.5.2", "", { "dependencies": { "@bufbuild/protobuf": "^2.5.2", "@bufbuild/protoplugin": "2.5.2" }, "bin": { "protoc-gen-es": "bin/protoc-gen-es" } }, "sha512-G9rvJ3CH1MXxUTppBJLdhc+wS2m6LR1Tvi0sGoDDChmiqJVkOhIeYrqtGMqqLqiE44I7uJGDaYcn1PG3aIGJUg=="], - - "@bufbuild/protoplugin": ["@bufbuild/protoplugin@2.5.2", "", { "dependencies": { "@bufbuild/protobuf": "2.5.2", "@typescript/vfs": "^1.5.2", "typescript": "5.4.5" } }, "sha512-7d/NUae/ugs/qgHEYOwkVWGDE3Bf/xjuGviVFs38+MLRdwiHNTiuvzPVwuIPo/1wuZCZn3Nax1cg1owLuY72xw=="], - "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@11.0.3", "", { "dependencies": { "@chevrotain/gast": "11.0.3", "@chevrotain/types": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ=="], "@chevrotain/gast": ["@chevrotain/gast@11.0.3", "", { "dependencies": { "@chevrotain/types": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q=="], @@ -391,9 +367,9 @@ "@chromatic-com/storybook": ["@chromatic-com/storybook@3.2.6", "", { "dependencies": { "chromatic": "^11.15.0", "filesize": "^10.0.12", "jsonfile": "^6.1.0", "react-confetti": "^6.1.0", "strip-ansi": "^7.1.0" }, "peerDependencies": { "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0" } }, "sha512-FDmn5Ry2DzQdik+eq2sp/kJMMT36Ewe7ONXUXM2Izd97c7r6R/QyGli8eyh/F0iyqVvbLveNYFyF0dBOJNwLqw=="], - "@connectrpc/connect": ["@connectrpc/connect@2.0.2", "", { "peerDependencies": { "@bufbuild/protobuf": "^2.2.0" } }, "sha512-xZuylIUNvNlH52e/4eQsZvY4QZyDJRtEFEDnn/yBrv5Xi5ZZI/p8X+GAHH35ucVaBvv9u7OzHZo8+tEh1EFTxA=="], + "@connectrpc/connect": ["@connectrpc/connect@2.0.0-rc.3", "", { "peerDependencies": { "@bufbuild/protobuf": "^2.2.0" } }, "sha512-ARBt64yEyKbanyRETTjcjJuHr2YXorzQo0etyS5+P6oSeW8xEuzajA9g+zDnMcj1hlX2dQE93foIWQGfpru7gQ=="], - "@connectrpc/protoc-gen-connect-es": ["@connectrpc/protoc-gen-connect-es@1.6.1", "", { "dependencies": { "@bufbuild/protobuf": "^1.10.0", "@bufbuild/protoplugin": "^1.10.0" }, "peerDependencies": { "@bufbuild/protoc-gen-es": "^1.10.0", "@connectrpc/connect": "1.6.1" }, "optionalPeers": ["@bufbuild/protoc-gen-es", "@connectrpc/connect"], "bin": { "protoc-gen-connect-es": "bin/protoc-gen-connect-es" } }, "sha512-0fHcaADd+GKM0I/koIQpmKg7b+QL18bXlggTUYEAlMFzsd4zN/ApG3235hdUcRyhrAOAItTXxh8ZAV/nNd43Gg=="], + "@connectrpc/connect-web": ["@connectrpc/connect-web@2.0.0-rc.3", "", { "peerDependencies": { "@bufbuild/protobuf": "^2.2.0", "@connectrpc/connect": "2.0.0-rc.3" } }, "sha512-w88P8Lsn5CCsA7MFRl2e6oLY4J/5toiNtJns/YJrlyQaWOy3RO8pDgkz+iIkG98RPMhj2thuBvsd3Cn4DKKCkw=="], "@csstools/color-helpers": ["@csstools/color-helpers@5.0.2", "", {}, "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA=="], @@ -1581,6 +1557,8 @@ "commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="], + "compare-versions": ["compare-versions@6.1.1", "", {}, "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg=="], + "compute-scroll-into-view": ["compute-scroll-into-view@3.1.1", "", {}, "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw=="], "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], @@ -1797,6 +1775,8 @@ "duplexify": ["duplexify@4.1.3", "", { "dependencies": { "end-of-stream": "^1.4.1", "inherits": "^2.0.3", "readable-stream": "^3.1.1", "stream-shift": "^1.0.2" } }, "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA=="], + "e2b": ["e2b@1.5.3", "", { "dependencies": { "@bufbuild/protobuf": "^2.2.2", "@connectrpc/connect": "2.0.0-rc.3", "@connectrpc/connect-web": "2.0.0-rc.3", "compare-versions": "^6.1.0", "openapi-fetch": "^0.9.7", "platform": "^1.3.6" } }, "sha512-DscfuCl8VS/J6LCM12325eQNk04HZ8BSVOplz0cMV6Ea4l1Ihy7NSfQLHCS3Db927kYyXLo3RxX1NWGgsmmYkg=="], + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], @@ -2729,6 +2709,8 @@ "pkg-types": ["pkg-types@2.1.0", "", { "dependencies": { "confbox": "^0.2.1", "exsolve": "^1.0.1", "pathe": "^2.0.3" } }, "sha512-wmJwA+8ihJixSoHKxZJRBQG1oY8Yr9pGLzRmSsNms0iNWyHHAlZCa7mmKiFR10YPZuz/2k169JiS/inOjBCZ2A=="], + "platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="], + "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], "pnp-webpack-plugin": ["pnp-webpack-plugin@1.7.0", "", { "dependencies": { "ts-pnp": "^1.1.6" } }, "sha512-2Rb3vm+EXble/sMXNSu6eoBx8e79gKqhNq9F5ZWW6ERNCTE/Q0wQNne5541tE5vKjfM8hpNCYL+LGc1YTfI0dg=="], @@ -3419,12 +3401,6 @@ "@babel/traverse/globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="], - "@bufbuild/protoplugin/typescript": ["typescript@5.4.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ=="], - - "@connectrpc/protoc-gen-connect-es/@bufbuild/protobuf": ["@bufbuild/protobuf@1.10.1", "", {}, "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ=="], - - "@connectrpc/protoc-gen-connect-es/@bufbuild/protoplugin": ["@bufbuild/protoplugin@1.10.1", "", { "dependencies": { "@bufbuild/protobuf": "1.10.1", "@typescript/vfs": "^1.4.0", "typescript": "4.5.2" } }, "sha512-LaSbfwabAFIvbVnbn8jWwElRoffCIxhVraO8arliVwWupWezHLXgqPHEYLXZY/SsAR+/YsFBQJa8tAGtNPJyaQ=="], - "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], @@ -3767,6 +3743,8 @@ "duplexify/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "e2b/openapi-fetch": ["openapi-fetch@0.9.8", "", { "dependencies": { "openapi-typescript-helpers": "^0.0.8" } }, "sha512-zM6elH0EZStD/gSiNlcPrzXcVQ/pZo3BDvC6CDwRDUt1dDzxlshpmQnpD6cZaJ39THaSmwVCxxRrPKNM1hHrDg=="], + "elliptic/bn.js": ["bn.js@4.12.1", "", {}, "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg=="], "error-ex/is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], @@ -4059,8 +4037,6 @@ "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "@connectrpc/protoc-gen-connect-es/@bufbuild/protoplugin/typescript": ["typescript@4.5.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-5BlMof9H1yGt0P8/WF+wPNw6GfctgGjXp5hkblpyT+8rkASSmkUKMXrxR0Xg8ThVCi/JnHQiKXeBaEwCeQwMFw=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], @@ -4397,6 +4373,8 @@ "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], + "e2b/openapi-fetch/openapi-typescript-helpers": ["openapi-typescript-helpers@0.0.8", "", {}, "sha512-1eNjQtbfNi5Z/kFhagDIaIRj6qqDzhjNJKz8cmMW0CVdGwT6e1GLbAfgI0d28VTJa1A8jz82jm/4dG8qNoNS8g=="], + "eslint-plugin-import/tsconfig-paths/json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], "eslint/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], diff --git a/package.json b/package.json index cfa4862..c777bdf 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ "<<<<<< Gen": "", "generate:infra": "bunx openapi-typescript https://raw.githubusercontent.com/e2b-dev/infra/main/spec/openapi.yml -o ./src/types/infra-api.d.ts", "generate:supabase": "bunx supabase@latest gen types typescript --schema public > src/types/database.types.ts --project-id $SUPABASE_PROJECT_ID", - "generate:envd": "buf generate --template ./spec/envd/buf.gen.yaml", "<<<<<< Scripts": "", "scripts:check-app-env": "bun scripts/check-app-env.ts", "scripts:build-storybook": "bun scripts/build-storybook.ts", @@ -40,7 +39,6 @@ "test:ui:integration": "bun scripts:check-app-env && vitest --ui src/__test__/integration/" }, "dependencies": { - "@connectrpc/connect": "^2.0.2", "@fumadocs/mdx-remote": "^1.2.0", "@google-cloud/storage": "^7.15.2", "@hookform/resolvers": "^3.10.0", @@ -81,6 +79,7 @@ "clsx": "^2.1.1", "cmdk": "^1.0.4", "date-fns": "^4.1.0", + "e2b": "^1.5.3", "fast-xml-parser": "^4.5.1", "fumadocs-core": "^15.0.6", "fumadocs-mdx": "^11.5.3", @@ -160,11 +159,7 @@ "tailwindcss-animate": "^1.0.7", "tsx": "^4.19.2", "typescript": "5.7.3", - "vitest": "^3.0.7", - "@bufbuild/buf": "^1.54.0", - "@bufbuild/protobuf": "^2.5.2", - "@bufbuild/protoc-gen-es": "^2.5.2", - "@connectrpc/protoc-gen-connect-es": "^1.6.1" + "vitest": "^3.0.7" }, "resolutions": { "whatwg-url": "^13", diff --git a/spec/envd/buf.gen.yaml b/spec/envd/buf.gen.yaml deleted file mode 100644 index a3317f9..0000000 --- a/spec/envd/buf.gen.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# buf.gen.yaml defines a local generation template. -# For details, see https://buf.build/docs/configuration/v2/buf-gen-yaml -version: v2 -plugins: - - local: protoc-gen-es - out: ./src/lib/clients/envd - opt: - - target=ts - - local: protoc-gen-connect-es - out: ./src/lib/clients/envd - opt: - - target=ts - -managed: - enabled: true - override: - - file_option: optimize_for - value: SPEED - -inputs: - - directory: spec/envd diff --git a/spec/envd/filesystem/filesystem.proto b/spec/envd/filesystem/filesystem.proto deleted file mode 100644 index f80c37c..0000000 --- a/spec/envd/filesystem/filesystem.proto +++ /dev/null @@ -1,124 +0,0 @@ -syntax = "proto3"; - -package filesystem; - -service Filesystem { - rpc Stat(StatRequest) returns (StatResponse); - rpc MakeDir(MakeDirRequest) returns (MakeDirResponse); - rpc Move(MoveRequest) returns (MoveResponse); - rpc ListDir(ListDirRequest) returns (ListDirResponse); - rpc Remove(RemoveRequest) returns (RemoveResponse); - - rpc WatchDir(WatchDirRequest) returns (stream WatchDirResponse); - - // Non-streaming versions of WatchDir - rpc CreateWatcher(CreateWatcherRequest) returns (CreateWatcherResponse); - rpc GetWatcherEvents(GetWatcherEventsRequest) returns (GetWatcherEventsResponse); - rpc RemoveWatcher(RemoveWatcherRequest) returns (RemoveWatcherResponse); -} - -message MoveRequest { - string source = 1; - string destination = 2; -} - -message MoveResponse { - EntryInfo entry = 1; -} - -message MakeDirRequest { - string path = 1; -} - -message MakeDirResponse { - EntryInfo entry = 1; -} - -message RemoveRequest { - string path = 1; -} - -message RemoveResponse {} - -message StatRequest { - string path = 1; -} - -message StatResponse { - EntryInfo entry = 1; -} - -message EntryInfo { - string name = 1; - FileType type = 2; - string path = 3; -} - -enum FileType { - FILE_TYPE_UNSPECIFIED = 0; - FILE_TYPE_FILE = 1; - FILE_TYPE_DIRECTORY = 2; -} - -message ListDirRequest { - string path = 1; - uint32 depth = 2; -} - -message ListDirResponse { - repeated EntryInfo entries = 1; -} - -message WatchDirRequest { - string path = 1; - bool recursive = 2; -} - -message FilesystemEvent { - string name = 1; - EventType type = 2; -} - -message WatchDirResponse { - oneof event { - StartEvent start = 1; - FilesystemEvent filesystem = 2; - KeepAlive keepalive = 3; - } - - message StartEvent {} - - message KeepAlive {} -} - -message CreateWatcherRequest { - string path = 1; - bool recursive = 2; -} - -message CreateWatcherResponse { - string watcher_id = 1; -} - -message GetWatcherEventsRequest { - string watcher_id = 1; -} - -message GetWatcherEventsResponse { - repeated FilesystemEvent events = 1; -} - -message RemoveWatcherRequest { - string watcher_id = 1; -} - -message RemoveWatcherResponse {} - -enum EventType { - EVENT_TYPE_UNSPECIFIED = 0; - EVENT_TYPE_CREATE = 1; - EVENT_TYPE_WRITE = 2; - EVENT_TYPE_REMOVE = 3; - EVENT_TYPE_RENAME = 4; - EVENT_TYPE_CHMOD = 5; -} diff --git a/src/__test__/integration/middleware.test.ts b/src/__test__/integration/middleware.test.ts index 2b06ef5..bdeebe6 100644 --- a/src/__test__/integration/middleware.test.ts +++ b/src/__test__/integration/middleware.test.ts @@ -172,7 +172,7 @@ describe('Middleware Integration Tests', () => { expect(redirectCalls.length).toBeGreaterThan(0) if (redirectCalls.length > 0) { - const redirectUrl = redirectCalls[0][0].toString() + const redirectUrl = redirectCalls[0]![0].toString() expect(redirectUrl).toContain(AUTH_URLS.SIGN_IN) } }) @@ -214,7 +214,7 @@ describe('Middleware Integration Tests', () => { const redirectCalls = vi.mocked(NextResponse.redirect).mock.calls expect(redirectCalls.length).toBeGreaterThan(0) if (redirectCalls.length > 0) { - expect(redirectCalls[0][0].toString()).toContain('default-team') + expect(redirectCalls[0]![0].toString()).toContain('default-team') } }) }) @@ -243,7 +243,7 @@ describe('Middleware Integration Tests', () => { const redirectCalls = vi.mocked(NextResponse.redirect).mock.calls expect(redirectCalls.length).toBeGreaterThan(0) if (redirectCalls.length > 0) { - const url = redirectCalls[0][0].toString() + const url = redirectCalls[0]![0].toString() expect(url).toContain(PROTECTED_URLS.DASHBOARD) expect(url).not.toContain('tampered-team-id') } @@ -318,7 +318,7 @@ describe('Middleware Integration Tests', () => { const redirectCalls = vi.mocked(NextResponse.redirect).mock.calls expect(redirectCalls.length).toBeGreaterThan(0) if (redirectCalls.length > 0) { - expect(redirectCalls[0][0].toString()).toContain('default-team') + expect(redirectCalls[0]![0].toString()).toContain('default-team') } }) @@ -354,7 +354,7 @@ describe('Middleware Integration Tests', () => { const redirectCalls = vi.mocked(NextResponse.redirect).mock.calls expect(redirectCalls.length).toBeGreaterThan(0) if (redirectCalls.length > 0) { - expect(redirectCalls[0][0].toString()).toContain( + expect(redirectCalls[0]![0].toString()).toContain( PROTECTED_URLS.NEW_TEAM ) } @@ -394,7 +394,7 @@ describe('Middleware Integration Tests', () => { const redirectCalls = vi.mocked(NextResponse.redirect).mock.calls expect(redirectCalls.length).toBeGreaterThan(0) if (redirectCalls.length > 0) { - expect(redirectCalls[0][0].toString()).toContain('/') + expect(redirectCalls[0]![0].toString()).toContain('/') } }) }) diff --git a/src/app/api/sandboxes/[id]/list/route.ts b/src/app/api/sandboxes/[id]/list/route.ts new file mode 100644 index 0000000..ff021d1 --- /dev/null +++ b/src/app/api/sandboxes/[id]/list/route.ts @@ -0,0 +1,68 @@ +import { NextRequest } from 'next/server' +import { SandboxPool } from '@/lib/clients/sandbox-pool' +import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { createRouteClient } from '@/lib/clients/supabase/server' +import { FileType } from 'e2b' +import { FsEntry, FsFileType } from '@/types/filesystem' + +export const maxDuration = 60 // quick, single call +export const dynamic = 'force-dynamic' +export const fetchCache = 'force-no-store' + +/** + * GET /api/sandboxes/{id}/list?dir=/path&team= + * Returns JSON array of EntryInfo for the directory. + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + + const { searchParams } = new URL(request.url) + const dir = searchParams.get('dir') ?? '/' + const teamId = searchParams.get('team') ?? '' + + const supabase = createRouteClient(request) + const { + data: { session }, + } = await supabase.auth.getSession() + if (!session?.access_token) + return new Response('Unauthorized', { status: 401 }) + + const opts = { + headers: { + ...SUPABASE_AUTH_HEADERS(session.access_token, teamId), + }, + } + + let entries: FsEntry[] = [] + let error: unknown + try { + const sandbox = await SandboxPool.acquire(id, opts) + const raw = await sandbox.files.list(dir) + entries = raw.map((e) => ({ + name: e.name, + path: e.path, + type: + e.type === FileType.DIR + ? ('dir' as FsFileType) + : ('file' as FsFileType), + })) + } catch (err) { + error = err + } finally { + await SandboxPool.release(id) + } + + if (error) { + console.error('Dir list error', error) + return new Response('Failed to list directory', { status: 500 }) + } + + return new Response(JSON.stringify(entries), { + headers: { + 'Content-Type': 'application/json', + }, + }) +} diff --git a/src/app/api/sandboxes/[id]/watch/route.ts b/src/app/api/sandboxes/[id]/watch/route.ts new file mode 100644 index 0000000..11e1813 --- /dev/null +++ b/src/app/api/sandboxes/[id]/watch/route.ts @@ -0,0 +1,114 @@ +import { NextRequest } from 'next/server' +import { WatchDirPool } from '@/lib/clients/watch-dir-pool' +import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { createRouteClient } from '@/lib/clients/supabase/server' +import { VERBOSE } from '@/configs/flags' +import { logDebug } from '@/lib/clients/logger' + +export const maxDuration = 600 // 10 minutes + +/** + * SSE endpoint that streams filesystem events for a sandbox directory. + * + * Request: GET /api/sandboxes/{id}/watch?dir=/path + * + * The caller must be authenticated (via Supabase session cookie) so that we + * can forward the JWT to the E2B backend. + */ +export async function GET( + request: NextRequest, + { + params, + }: { + params: Promise<{ id: string }> + } +) { + const { id } = await params + + const { searchParams } = new URL(request.url) + const dir = searchParams.get('dir') ?? '/' + const teamId = searchParams.get('team') ?? '' + + if (VERBOSE) logDebug('WatchRoute.init', { id, dir, teamId }) + + const supabase = createRouteClient(request) + + const { + data: { session }, + } = await supabase.auth.getSession() + if (!session?.access_token) { + return new Response('Unauthorized', { status: 401 }) + } + + const sandboxOpts = { + headers: { + ...SUPABASE_AUTH_HEADERS(session.access_token, teamId), + }, + } + + if (VERBOSE) logDebug('WatchRoute.sandboxOpts') + + let watcherReleased = false + let ping: ReturnType | undefined + + const stream = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder() + + const onEvent = (ev: unknown) => { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(ev)}\n\n`)) + } + + if (VERBOSE) logDebug('WatchRoute.acquireWatcher') + + await WatchDirPool.acquire(id, dir, onEvent, sandboxOpts) + + if (VERBOSE) logDebug('WatchRoute.watcherReady') + + // periodic comment ping to keep intermediary proxies / clients happy + ping = setInterval(() => { + try { + controller.enqueue(encoder.encode(`: ping\n\n`)) + } catch (err) { + // controller was closed—stop pings to avoid uncaught exceptions + if (VERBOSE) logDebug('WatchRoute.pingError', err) + if (ping) clearInterval(ping) + } + }, 15_000) + + request.signal.addEventListener('abort', () => { + if (!watcherReleased) { + watcherReleased = true + if (VERBOSE) logDebug('WatchRoute.abort') + // Do NOT release; keep watcher alive for GRACE_MS so quick tab switches reuse it + } + if (ping) clearInterval(ping) + controller.close() + }) + }, + /** + * This runs if the ReadableStream is cancelled *without* the `abort` event + * (for example `response.body.cancel()` or an abrupt GC). At this point we + * no longer have a reference to the original `onEvent` callback, so we + * cannot call `WatchDirPool.release(...)` accurately. Instead we just mark + * the watcher as released; the pool's idle-timer will close the underlying + * gRPC stream after `GRACE_MS` once it sees the ref-count hasn't changed. + */ + cancel() { + if (!watcherReleased) { + watcherReleased = true + if (VERBOSE) logDebug('WatchRoute.cancelStream') + void WatchDirPool.release(id, dir, () => {}) + } + if (ping) clearInterval(ping) + }, + }) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }) +} diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 36c1aec..a5c3957 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -2,7 +2,7 @@ import type { Metadata } from 'next' import Sidebar from '@/features/dashboard/sidebar/sidebar' import { DashboardTitleProvider } from '@/features/dashboard/dashboard-title-provider' import { Suspense } from 'react' -import { ServerContextProvider } from '@/lib/hooks/use-server-context' +import { ServerContextProvider } from '@/features/dashboard/server-context' import { resolveTeamIdInServerComponent, resolveTeamSlugInServerComponent, diff --git a/src/features/dashboard/sandbox/context.tsx b/src/features/dashboard/sandbox/context.tsx new file mode 100644 index 0000000..8b0f3ff --- /dev/null +++ b/src/features/dashboard/sandbox/context.tsx @@ -0,0 +1,82 @@ +'use client' + +import React, { + createContext, + useContext, + ReactNode, + useLayoutEffect, + useState, +} from 'react' +import { SandboxInfo } from '@/types/api' + +interface SandboxState { + secondsLeft: number + isRunning: boolean +} + +interface SandboxContextValue { + sandboxInfo: SandboxInfo + state: SandboxState +} + +const SandboxContext = createContext(null) + +export function useSandboxContext() { + const context = useContext(SandboxContext) + if (!context) { + throw new Error('useSandboxContext must be used within a SandboxProvider') + } + return context +} + +interface SandboxProviderProps { + children: ReactNode + sandboxInfo: SandboxInfo +} + +export function SandboxProvider({ + children, + sandboxInfo, +}: SandboxProviderProps) { + const [secondsLeft, setSecondsLeft] = useState(0) + const [isRunning, setIsRunning] = useState(false) + + useLayoutEffect(() => { + const interval = setInterval(() => { + const now = new Date() + const endAt = new Date(sandboxInfo.endAt) + + if (endAt <= now) { + setIsRunning(false) + setSecondsLeft(0) + clearInterval(interval) + } else { + setIsRunning(true) + } + + const diff = endAt.getTime() - now.getTime() + setSecondsLeft(Math.max(0, Math.floor(diff / 1000))) + }, 1000) + + return () => { + if (!interval) return + clearInterval(interval) + } + }, [sandboxInfo.sandboxID, sandboxInfo.endAt]) + + const state = { + secondsLeft, + isRunning, + } + + return ( + + {children} + + ) +} diff --git a/src/features/dashboard/sandbox/inspect/context.tsx b/src/features/dashboard/sandbox/inspect/context.tsx new file mode 100644 index 0000000..5fd9e26 --- /dev/null +++ b/src/features/dashboard/sandbox/inspect/context.tsx @@ -0,0 +1,190 @@ +'use client' + +import React, { + createContext, + useContext, + useRef, + ReactNode, + useLayoutEffect, + useMemo, +} from 'react' +import { FsEntry } from '@/types/filesystem' +import { createFilesystemStore, type FilesystemStore } from './filesystem/store' +import { FilesystemNode, FilesystemOperations } from './filesystem/types' +import { FilesystemEventManager } from './filesystem/events-manager' +import { getParentPath, normalizePath } from '@/lib/utils/filesystem' +import { useSandboxContext } from '../context' + +interface SandboxInspectContextValue { + store: FilesystemStore + operations: FilesystemOperations + eventManager: FilesystemEventManager | null +} + +const SandboxInspectContext = createContext( + null +) + +interface SandboxInspectProviderProps { + children: ReactNode + teamId: string + rootPath: string + seedEntries?: FsEntry[] +} + +export function SandboxInspectProvider({ + children, + teamId, + rootPath, + seedEntries, +}: SandboxInspectProviderProps) { + const { sandboxInfo } = useSandboxContext() + const storeRef = useRef(null) + const eventManagerRef = useRef(null) + const operationsRef = useRef(null) + + const sandboxId = useMemo( + () => sandboxInfo.sandboxID + '-' + sandboxInfo.clientID, + [sandboxInfo.sandboxID, sandboxInfo.clientID] + ) + + /* + * ---------- synchronous store initialisation ---------- + * We want the tree to render immediately using the "seedEntries" streamed from the + * server component (see page.tsx). We therefore build / populate the Zustand store + * right here during render, instead of doing it later inside an effect. + */ + { + const normalizedRoot = normalizePath(rootPath) + const needsNewStore = + !storeRef.current || + storeRef.current.getState().rootPath !== normalizedRoot + + if (needsNewStore) { + // stop previous watcher (if any) + if (eventManagerRef.current) { + eventManagerRef.current.stopWatching() + eventManagerRef.current = null + } + + storeRef.current = createFilesystemStore(rootPath) + + const state = storeRef.current.getState() + + const rootName = + normalizedRoot === '/' ? '/' : normalizedRoot.split('/').pop() || '' + + state.addNodes(getParentPath(normalizedRoot), [ + { + name: rootName, + path: normalizedRoot, + type: 'dir', + isExpanded: true, + isLoaded: true, + children: [], + }, + ]) + + if (seedEntries && seedEntries.length) { + const seedNodes: FilesystemNode[] = seedEntries.map((entry) => { + const base = { + name: entry.name, + path: normalizePath(entry.path), + } + + if (entry.type === 'dir') { + return { + ...base, + type: 'dir' as const, + isExpanded: false, + isLoaded: false, + children: [], + } + } + + return { + ...base, + type: 'file' as const, + } + }) + + state.addNodes(normalizedRoot, seedNodes) + } + + const store = storeRef.current + operationsRef.current = { + loadDirectory: async (path: string) => { + await eventManagerRef.current?.loadDirectory(path) + }, + selectNode: (path: string) => { + store.getState().setSelected(path) + }, + toggleDirectory: async (path: string) => { + const normalizedPath = normalizePath(path) + const state = store.getState() + const node = state.getNode(normalizedPath) + + if (!node || node.type !== 'dir') return + + const newExpandedState = !node.isExpanded + state.setExpanded(normalizedPath, newExpandedState) + + if (newExpandedState && !node.isLoaded) { + await eventManagerRef.current?.loadDirectory(normalizedPath) + } + }, + refreshDirectory: async (path: string) => { + await eventManagerRef.current?.refreshDirectory(path) + }, + } + } + } + + /* + * ---------- watcher (side-effect) initialisation / cleanup ---------- + */ + useLayoutEffect(() => { + if (!storeRef.current) return + + // (re)create the event-manager when sandbox / team / root changes + if (eventManagerRef.current) { + eventManagerRef.current.stopWatching() + } + eventManagerRef.current = new FilesystemEventManager( + storeRef.current, + sandboxId, + teamId, + rootPath + ) + + return () => { + eventManagerRef.current?.stopWatching() + } + }, [sandboxId, teamId, rootPath]) + + if (!storeRef.current || !operationsRef.current) { + return null // should never happen, but satisfies type-checker + } + + const contextValue: SandboxInspectContextValue = { + store: storeRef.current, + operations: operationsRef.current, + eventManager: eventManagerRef.current, + } + + return ( + + {children} + + ) +} + +export function useSandboxInspectContext(): SandboxInspectContextValue { + const context = useContext(SandboxInspectContext) + if (!context) { + throw new Error( + 'useSandboxInspectContext must be used within a SandboxInspectProvider' + ) + } + return context +} diff --git a/src/features/dashboard/sandbox/inspect/filesystem/events-manager.ts b/src/features/dashboard/sandbox/inspect/filesystem/events-manager.ts new file mode 100644 index 0000000..21f43e0 --- /dev/null +++ b/src/features/dashboard/sandbox/inspect/filesystem/events-manager.ts @@ -0,0 +1,217 @@ +import { FsEvent, FsEntry } from '@/types/filesystem' +import type { FilesystemStore } from './store' +import { FilesystemNode } from './types' +import { normalizePath, joinPath, getParentPath } from '@/lib/utils/filesystem' + +export class FilesystemEventManager { + private unsubscribe?: () => void + private readonly rootPath: string + private store: FilesystemStore + private sandboxId: string + private teamId: string + + constructor( + store: FilesystemStore, + sandboxId: string, + teamId: string, + rootPath: string + ) { + this.store = store + this.sandboxId = sandboxId + this.teamId = teamId + this.rootPath = normalizePath(rootPath) + + void this.startRootWatcher() + } + + private async startRootWatcher(): Promise { + if (this.unsubscribe) return + + this.unsubscribe = openWatcher( + this.sandboxId, + this.rootPath, + this.teamId, + (event) => this.handleFilesystemEvent(event) + ) + } + + stopWatching(): void { + this.unsubscribe?.() + this.unsubscribe = undefined + } + + private handleFilesystemEvent(event: FsEvent): void { + const { type, name } = event + + // "name" is relative to the watched root; construct absolute path + const normalizedPath = normalizePath(joinPath(this.rootPath, name)) + const parentDir = normalizePath( + joinPath(this.rootPath, getParentPath(name)) + ) + + const state = this.store.getState() + const parentNode = state.getNode(parentDir) + + switch (type) { + case 'create': + case 'rename': + if (!parentNode || parentNode.type !== 'dir' || !parentNode.isLoaded) { + console.debug( + `Skip refresh for '${normalizedPath}' because parent directory '${parentDir}' does not exist in store` + ) + return + } + + console.log( + `Filesystem ${type} event for '${normalizedPath}', refreshing parent '${parentDir}'` + ) + void this.refreshDirectory(parentDir) + break + + case 'remove': + if (!state.getNode(normalizedPath)) { + console.debug( + `Skip remove for '${normalizedPath}' because node does not exist in store` + ) + return + } + + console.log( + `Filesystem REMOVE event for '${normalizedPath}', removing node from store` + ) + this.handleRemoveEvent(normalizedPath) + break + + case 'write': + case 'chmod': + console.debug(`Ignoring ${type} event for '${normalizedPath}'`) + break + + default: + console.warn(`Unknown filesystem event type: ${type}`) + break + } + } + + private handleRemoveEvent(removedPath: string): void { + const state = this.store.getState() + const node = state.getNode(removedPath) + + if (!node) { + console.debug( + `Node '${removedPath}' not found in store, skipping removal` + ) + return + } + + state.removeNode(removedPath) + console.log(`Successfully removed node '${removedPath}' from store`) + } + + async loadDirectory(path: string): Promise { + const normalizedPath = normalizePath(path) + const state = this.store.getState() + const node = state.getNode(normalizedPath) + + if ( + !node || + node.type !== 'dir' || + node.isLoaded || + state.loadingPaths.has(normalizedPath) + ) + return + + state.setLoading(normalizedPath, true) + state.setError(normalizedPath) // clear any previous errors + + try { + const entries = await listDir(this.sandboxId, normalizedPath, this.teamId) + + const nodes: FilesystemNode[] = entries.map((entry) => { + if (entry.type === 'dir') { + return { + name: entry.name, + path: entry.path, + type: 'dir', + isExpanded: false, + isSelected: false, + isLoaded: false, + children: [], + } + } else { + return { + name: entry.name, + path: entry.path, + type: 'file', + isSelected: false, + } + } + }) + + state.addNodes(normalizedPath, nodes) + state.updateNode(normalizedPath, { isLoaded: true }) + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Failed to load directory' + state.setError(normalizedPath, errorMessage) + console.error(`Failed to load directory ${normalizedPath}:`, error) + } finally { + state.setLoading(normalizedPath, false) + } + } + + async refreshDirectory(path: string): Promise { + const normalizedPath = normalizePath(path) + const state = this.store.getState() + + state.updateNode(normalizedPath, { isLoaded: false }) + + const node = state.getNode(normalizedPath) + if (node && node.type === 'dir') { + const childrenPaths = [...node.children] + for (const childPath of childrenPaths) { + state.removeNode(childPath) + } + } + + await this.loadDirectory(normalizedPath) + } +} + +async function listDir( + sandboxId: string, + dir: string, + teamId: string +): Promise { + const url = `/api/sandboxes/${sandboxId}/list?dir=${encodeURIComponent(dir)}&team=${teamId}` + return fetch(url, { credentials: 'include' }).then((r) => { + if (!r.ok) throw new Error(`List failed ${r.status}`) + return r.json() + }) +} + +function openWatcher( + sandboxId: string, + dir: string, + teamId: string, + onEvent: (e: FsEvent) => void +): () => void { + let es: EventSource | null + + const connect = () => { + es = new EventSource( + `/api/sandboxes/${sandboxId}/watch?dir=${encodeURIComponent(dir)}&team=${teamId}`, + { withCredentials: true } + ) + + es.onmessage = (ev) => onEvent(JSON.parse(ev.data)) + es.onerror = () => { + // auto-reconnect in 1 s + es?.close() + setTimeout(connect, 1_000) + } + } + + connect() + return () => es?.close() +} diff --git a/src/features/dashboard/sandbox/inspect/filesystem/store.ts b/src/features/dashboard/sandbox/inspect/filesystem/store.ts new file mode 100644 index 0000000..92ca3aa --- /dev/null +++ b/src/features/dashboard/sandbox/inspect/filesystem/store.ts @@ -0,0 +1,312 @@ +'use client' + +import { create } from 'zustand' +import { immer } from 'zustand/middleware/immer' +import { enableMapSet } from 'immer' +import { + normalizePath, + getParentPath, + isChildPath, +} from '@/lib/utils/filesystem' +import { FsEntry } from '@/types/filesystem' +import { FilesystemNode } from './types' + +enableMapSet() + +interface FilesystemStatics { + rootPath: string +} + +// mutable state +export interface FilesystemState { + nodes: Map + selectedPath?: string + loadingPaths: Set + errorPaths: Map +} + +// mutations/actions that modify state +export interface FilesystemMutations { + addNodes: (parentPath: string, nodes: FilesystemNode[]) => void + removeNode: (path: string) => void + updateNode: (path: string, updates: Partial) => void + setExpanded: (path: string, expanded: boolean) => void + setSelected: (path: string) => void + setLoading: (path: string, loading: boolean) => void + setError: (path: string, error?: string) => void + reset: () => void +} + +// computed/derived values +export interface FilesystemComputed { + getChildren: (path: string) => FilesystemNode[] + getNode: (path: string) => FilesystemNode | undefined + isExpanded: (path: string) => boolean + isSelected: (path: string) => boolean + hasChildren: (path: string) => boolean +} + +// combined store type +export type FilesystemStoreData = FilesystemStatics & + FilesystemState & + FilesystemMutations & + FilesystemComputed + +// to retain reference-stable arrays of children per directory path +const childrenCache: Map = + new Map() + +export const createFilesystemStore = (rootPath: string) => + create()( + immer((set, get) => ({ + rootPath: normalizePath(rootPath), + + nodes: new Map(), + loadingPaths: new Set(), + errorPaths: new Map(), + + addNodes: (parentPath: string, nodes: FilesystemNode[]) => { + const normalizedParentPath = normalizePath(parentPath) + + set((state: FilesystemState) => { + let parentNode = state.nodes.get(normalizedParentPath) + + if (!parentNode) { + const parentName = + normalizedParentPath === '/' + ? '/' + : normalizedParentPath.split('/').pop() || '' + parentNode = { + name: parentName, + path: normalizedParentPath, + type: 'dir', + isExpanded: false, + children: [], + } + state.nodes.set(normalizedParentPath, parentNode) + } + + if (parentNode.type === 'file') { + console.error('Parent node is a file', parentNode) + return + } + + if (!parentNode.children) { + parentNode.children = [] + } + + for (const node of nodes) { + const normalizedPath = normalizePath(node.path) + + state.nodes.set(normalizedPath, { + ...node, + path: normalizedPath, + }) + + if ( + normalizedPath !== normalizedParentPath && + !parentNode.children.includes(normalizedPath) + ) { + parentNode.children.push(normalizedPath) + } + } + + parentNode.children.sort((a: string, b: string) => { + const nodeA = state.nodes.get(a) + const nodeB = state.nodes.get(b) + + if (!nodeA || !nodeB) return 0 + + // directories first + if (nodeA.type === 'dir' && nodeB.type === 'file') return -1 + if (nodeA.type === 'file' && nodeB.type === 'dir') return 1 + + // then alphabetically + return nodeA.name.localeCompare(nodeB.name) + }) + }) + }, + + removeNode: (path: string) => { + const normalizedPath = normalizePath(path) + + set((state: FilesystemState) => { + const node = state.nodes.get(normalizedPath) + if (!node) return + + const parentPath = getParentPath(normalizedPath) + const parentNode = state.nodes.get(parentPath) + if (parentNode && parentNode.type === 'dir') { + parentNode.children = parentNode.children.filter( + (childPath: string) => childPath !== normalizedPath + ) + } + + const toRemove = [normalizedPath] + for (const [nodePath] of state.nodes) { + if (isChildPath(normalizedPath, nodePath)) { + toRemove.push(nodePath) + } + } + + for (const pathToRemove of toRemove) { + state.nodes.delete(pathToRemove) + state.loadingPaths.delete(pathToRemove) + state.errorPaths.delete(pathToRemove) + + if (state.selectedPath === pathToRemove) { + state.selectedPath = undefined + } + } + }) + }, + + updateNode: (path: string, updates: Partial) => { + const normalizedPath = normalizePath(path) + + set((state: FilesystemState) => { + const node = state.nodes.get(normalizedPath) + if (node) { + Object.assign(node, updates) + } + }) + }, + + setExpanded: (path: string, expanded: boolean) => { + const normalizedPath = normalizePath(path) + + set((state: FilesystemState) => { + const node = state.nodes.get(normalizedPath) + + if (!node) return + + if (node?.type === 'file') { + console.error('Cannot expand file', node) + return + } + + node.isExpanded = expanded + }) + }, + + setSelected: (path: string) => { + const normalizedPath = normalizePath(path) + + set((state: FilesystemState) => { + if (state.selectedPath) { + const prevNode = state.nodes.get(state.selectedPath) + + if (!prevNode) return + + prevNode.isSelected = false + } + + const node = state.nodes.get(normalizedPath) + + if (!node) return + + node.isSelected = true + state.selectedPath = normalizedPath + }) + }, + + setLoading: (path: string, loading: boolean) => { + const normalizedPath = normalizePath(path) + + set((state: FilesystemState) => { + if (loading) { + state.loadingPaths.add(normalizedPath) + } else { + state.loadingPaths.delete(normalizedPath) + } + + const node = state.nodes.get(normalizedPath) + + if (!node || node.type === 'file') return + + node.isLoading = loading + }) + }, + + setError: (path: string, error?: string) => { + const normalizedPath = normalizePath(path) + + set((state: FilesystemState) => { + if (error) { + state.errorPaths.set(normalizedPath, error) + } else { + state.errorPaths.delete(normalizedPath) + } + + const node = state.nodes.get(normalizedPath) + + if (!node || node.type === 'file') return + + node.error = error + }) + }, + + reset: () => { + set((state: FilesystemState) => { + state.nodes.clear() + state.selectedPath = undefined + state.loadingPaths.clear() + state.errorPaths.clear() + }) + }, + + getChildren: (path: string) => { + const normalizedPath = normalizePath(path) + const state = get() + const node = state.nodes.get(normalizedPath) + + if (!node || node.type === 'file') return [] + + const cached = childrenCache.get(normalizedPath) + if (cached && cached.ref === node.children) { + return cached.result + } + + const result = node.children + .map((childPath) => state.nodes.get(childPath)) + .filter((child): child is FilesystemNode => child !== undefined) + + childrenCache.set(normalizedPath, { ref: node.children, result }) + return result + }, + + getNode: (path: string) => { + const normalizedPath = normalizePath(path) + return get().nodes.get(normalizedPath) + }, + + isExpanded: (path: string) => { + const normalizedPath = normalizePath(path) + const node = get().nodes.get(normalizedPath) + + if (!node || node.type === 'file') return false + + return !!node.isExpanded + }, + + isSelected: (path: string) => { + const normalizedPath = normalizePath(path) + const node = get().nodes.get(normalizedPath) + + if (!node) return false + + return !!node.isSelected + }, + + hasChildren: (path: string) => { + const normalizedPath = normalizePath(path) + const node = get().nodes.get(normalizedPath) + + if (!node || node.type === 'file') return false + + return node.children.length > 0 + }, + })) + ) + +export type FilesystemStore = ReturnType diff --git a/src/features/dashboard/sandbox/inspect/filesystem/types.ts b/src/features/dashboard/sandbox/inspect/filesystem/types.ts new file mode 100644 index 0000000..a708673 --- /dev/null +++ b/src/features/dashboard/sandbox/inspect/filesystem/types.ts @@ -0,0 +1,29 @@ +import { FsFileType } from '@/types/filesystem' + +interface FilesystemDir { + type: 'dir' + name: string + path: string + children: string[] // paths of children + isExpanded?: boolean + isLoaded?: boolean + isSelected?: boolean + isLoading?: boolean + error?: string +} + +interface FilesystemFile { + type: 'file' + name: string + path: string + isSelected?: boolean +} + +export type FilesystemNode = FilesystemDir | FilesystemFile + +export interface FilesystemOperations { + loadDirectory: (path: string) => Promise + selectNode: (path: string) => void + toggleDirectory: (path: string) => Promise + refreshDirectory: (path: string) => Promise +} diff --git a/src/features/dashboard/sandbox/inspect/hooks/use-directory.ts b/src/features/dashboard/sandbox/inspect/hooks/use-directory.ts new file mode 100644 index 0000000..90f25fd --- /dev/null +++ b/src/features/dashboard/sandbox/inspect/hooks/use-directory.ts @@ -0,0 +1,75 @@ +'use client' + +import { useMemo } from 'react' +import { useSandboxInspectContext } from '../context' +import { FilesystemNode } from '../filesystem/types' +import { useStore } from 'zustand' + +/** + * Hook for accessing directory children with automatic updates + */ +export function useDirectoryChildren(path: string): FilesystemNode[] { + const { store } = useSandboxInspectContext() + + return useStore(store, (state) => state.getChildren(path)) +} + +/** + * Hook for accessing directory state (expanded, loading, error) + */ +export function useDirectoryState(path: string) { + const { store } = useSandboxInspectContext() + + const isExpanded = useStore(store, (state) => state.isExpanded(path)) + const isLoading = useStore(store, (state) => state.loadingPaths.has(path)) + const hasError = useStore(store, (state) => state.errorPaths.has(path)) + const error = useStore(store, (state) => state.errorPaths.get(path)) + const isLoaded = useStore(store, (state) => { + const node = state.getNode(path) + return node?.type === 'dir' ? !!node?.isLoaded : undefined + }) + const hasChildren = useStore(store, (state) => state.hasChildren(path)) + + return useMemo( + () => ({ + isExpanded, + isLoading, + hasError, + error, + isLoaded, + hasChildren, + }), + [isExpanded, isLoading, hasError, error, isLoaded, hasChildren] + ) +} + +/** + * Hook for directory operations + */ +export function useDirectoryOperations(path: string) { + const { operations } = useSandboxInspectContext() + + return useMemo( + () => ({ + toggle: () => operations.toggleDirectory(path), + load: () => operations.loadDirectory(path), + refresh: () => operations.refreshDirectory(path), + }), + [operations, path] + ) +} + +/** + * Combined hook for directory data and operations + */ +export function useDirectory(path: string) { + const children = useDirectoryChildren(path) + const state = useDirectoryState(path) + const ops = useDirectoryOperations(path) + + return { + children, + ...state, + ...ops, + } +} diff --git a/src/features/dashboard/sandbox/inspect/hooks/use-filesystem.ts b/src/features/dashboard/sandbox/inspect/hooks/use-filesystem.ts new file mode 100644 index 0000000..3718a80 --- /dev/null +++ b/src/features/dashboard/sandbox/inspect/hooks/use-filesystem.ts @@ -0,0 +1,12 @@ +'use client' + +import { useSandboxInspectContext } from '../context' +import type { FilesystemOperations } from '../filesystem/types' + +/** + * Main hook for accessing filesystem operations + */ +export function useFilesystem(): FilesystemOperations { + const { operations } = useSandboxInspectContext() + return operations +} diff --git a/src/features/dashboard/sandbox/inspect/hooks/use-node.ts b/src/features/dashboard/sandbox/inspect/hooks/use-node.ts new file mode 100644 index 0000000..8baf280 --- /dev/null +++ b/src/features/dashboard/sandbox/inspect/hooks/use-node.ts @@ -0,0 +1,83 @@ +'use client' + +import { useMemo } from 'react' +import { useSandboxInspectContext } from '../context' +import type { FilesystemNode } from '../filesystem/types' +import { useStore } from 'zustand' + +/** + * Hook for accessing a specific filesystem node + */ +export function useFilesystemNode(path: string): FilesystemNode | undefined { + const { store } = useSandboxInspectContext() + + return useStore(store, (state) => state.getNode(path)) +} + +/** + * Hook for accessing node selection state + */ +export function useNodeSelection(path: string) { + const { store, operations } = useSandboxInspectContext() + + const isSelected = useStore(store, (state) => state.isSelected(path)) + + const select = useMemo( + () => () => operations.selectNode(path), + [operations, path] + ) + + return { + isSelected, + select, + } +} + +/** + * Combined hook for node data and operations + */ +export function useNode(path: string) { + const node = useFilesystemNode(path) + const selection = useNodeSelection(path) + + return { + node, + ...selection, + } +} + +/** + * Hook for getting root directory children (commonly used) + */ +export function useRootChildren() { + const { store } = useSandboxInspectContext() + + return useStore(store, (state) => state.getChildren(state.rootPath)) +} + +/** + * Hook for getting selected node path + */ +export function useSelectedPath() { + const { store } = useSandboxInspectContext() + + return useStore(store, (state) => state.selectedPath) +} + +/** + * Hook for getting all loading paths + */ +export function useLoadingPaths() { + const { store } = useSandboxInspectContext() + + return useStore(store, (state) => state.loadingPaths) +} + +/** + * Hook for getting all error paths and their messages + */ +export function useErrorPaths() { + const { store } = useSandboxInspectContext() + + return useStore(store, (state) => state.errorPaths) +} diff --git a/src/features/dashboard/sandbox/overview.mermaid b/src/features/dashboard/sandbox/overview.mermaid new file mode 100644 index 0000000..283483e --- /dev/null +++ b/src/features/dashboard/sandbox/overview.mermaid @@ -0,0 +1,103 @@ +flowchart TD +subgraph SANDBOX_CONTEXT["Sandbox Context"] + direction TB + SANDBOX_PROVIDER["SandboxProvider"] + SANDBOX_STATE["Runtime State"] + + SANDBOX_PROVIDER -- "tracks lifecycle" --> SANDBOX_STATE +end + +%% ---------- New Server-side handling ---------- +subgraph SERVER_SIDE["Server Runtime (per Vercel instance)"] + direction TB + SANDBOX_POOL["SandboxPool"] + WATCH_POOL["WatchDirPool"] + LIST_ROUTE["/list Route"] + WATCH_ROUTE["/watch Route (SSE)"] + + SANDBOX_POOL -- "1 per sandbox" --> WATCH_POOL + LIST_ROUTE -- "files.list()" --> SANDBOX_POOL + WATCH_ROUTE -- "watchDir stream" --> WATCH_POOL +end + +subgraph INSPECT_CONTEXT["Inspect Context"] + direction TB + INSPECT_PROVIDER["SandboxInspectProvider"] + FILESYSTEM_STORE["FilesystemStore"] + EVENT_MANAGER["FilesystemEventManager (root recursive watcher)"] + OPERATIONS["Operations Object"] + + INSPECT_PROVIDER -- "creates singleton" --> FILESYSTEM_STORE + INSPECT_PROVIDER -- "creates with store" --> EVENT_MANAGER + INSPECT_PROVIDER -- "exposes interface" --> OPERATIONS + EVENT_MANAGER -- "writes FS data" --> FILESYSTEM_STORE + OPERATIONS -- "delegates to" --> EVENT_MANAGER + OPERATIONS -- "writes UI flags" --> FILESYSTEM_STORE +end + +%% Connections between client and server +EVENT_MANAGER -- "GET /list" --> LIST_ROUTE +EVENT_MANAGER -- "SSE /watch" --> WATCH_ROUTE + +subgraph HOOKS["Hook Layer"] + direction TB + FILESYSTEM_HOOKS["Filesystem Hooks"] + DIRECTORY_HOOKS["Directory Hooks"] + NODE_HOOKS["Node Hooks"] + + FILESYSTEM_HOOKS -- "subscribe to store slices" --> FILESYSTEM_STORE + DIRECTORY_HOOKS -- "subscribe to store slices" --> FILESYSTEM_STORE + NODE_HOOKS -- "subscribe to store slices" --> FILESYSTEM_STORE + + FILESYSTEM_HOOKS -- "return operations" --> OPERATIONS + DIRECTORY_HOOKS -- "return operations" --> OPERATIONS + NODE_HOOKS -- "return operations" --> OPERATIONS +end + +subgraph UI_COMPONENTS["UI Components"] + direction LR + FILE_TREE["FileTree"] + CODE_EDITOR["Code Editor"] + OTHER_UI["Other Components"] + + FILE_TREE -- "trigger user actions" --> USER_ACTIONS["User Actions"] + CODE_EDITOR -- "trigger user actions" --> USER_ACTIONS + OTHER_UI -- "trigger user actions" --> USER_ACTIONS +end + +subgraph E2B_REMOTE["E2B Cloud"] + REMOTE_SANDBOX["Remote Sandbox"] +end + +%% External connectivity +REMOTE_SANDBOX -- "SDK REST + unary gRPC" --> SANDBOX_POOL +REMOTE_SANDBOX -- "watchDir server-stream" --> WATCH_POOL + +%% Client-Server boundary +SERVER_SIDE -- "HTTP (JSON) / SSE" --> EVENT_MANAGER + +%% Data Flow: User Actions +USER_ACTIONS -- "call hooks" --> OPERATIONS +OPERATIONS -- "async list / watch" --> EVENT_MANAGER + +%% Flow inside client +FILESYSTEM_STORE -- "triggers re-renders" --> HOOKS +HOOKS -- "provide updated state" --> UI_COMPONENTS + +%% Hook Integration +HOOKS -- "consumed by" --> UI_COMPONENTS + +%% Styling +classDef contextClass fill:#E3F2FD,stroke:#1976D2,stroke-width:2px +classDef storeClass fill:#E8F5E8,stroke:#388E3C,stroke-width:2px +classDef managerClass fill:#FFF3E0,stroke:#F57C00,stroke-width:2px +classDef hooksClass fill:#FCE4EC,stroke:#C2185B,stroke-width:2px +classDef uiClass fill:#F1F8E9,stroke:#689F38,stroke-width:2px +classDef remoteClass fill:#FFEBEE,stroke:#D32F2F,stroke-width:2px + +class SANDBOX_PROVIDER,INSPECT_PROVIDER contextClass +class FILESYSTEM_STORE storeClass +class EVENT_MANAGER,OPERATIONS managerClass +class FILESYSTEM_HOOKS,DIRECTORY_HOOKS,NODE_HOOKS hooksClass +class FILE_TREE,CODE_EDITOR,OTHER_UI,USER_ACTIONS uiClass +class REMOTE_SANDBOX remoteClass \ No newline at end of file diff --git a/src/features/dashboard/sandboxes/table-config.tsx b/src/features/dashboard/sandboxes/table-config.tsx index c387d53..d1debc5 100644 --- a/src/features/dashboard/sandboxes/table-config.tsx +++ b/src/features/dashboard/sandboxes/table-config.tsx @@ -16,7 +16,7 @@ import { useMemo } from 'react' import { Button } from '@/ui/primitives/button' import { useRouter } from 'next/navigation' import { useTemplateTableStore } from '../templates/stores/table-store' -import { useServerContext } from '@/lib/hooks/use-server-context' +import { useServerContext } from '@/features/dashboard/server-context' import { JsonPopover } from '@/ui/json-popover' import posthog from 'posthog-js' import { logError } from '@/lib/clients/logger' diff --git a/src/lib/hooks/use-server-context.tsx b/src/features/dashboard/server-context.tsx similarity index 100% rename from src/lib/hooks/use-server-context.tsx rename to src/features/dashboard/server-context.tsx diff --git a/src/lib/clients/envd/filesystem/filesystem_connect.ts b/src/lib/clients/envd/filesystem/filesystem_connect.ts deleted file mode 100644 index 67a84f4..0000000 --- a/src/lib/clients/envd/filesystem/filesystem_connect.ts +++ /dev/null @@ -1,100 +0,0 @@ -// @generated by protoc-gen-connect-es v1.6.1 with parameter "target=ts" -// @generated from file filesystem/filesystem.proto (package filesystem, syntax proto3) -/* eslint-disable */ -// @ts-nocheck - -import { CreateWatcherRequest, CreateWatcherResponse, GetWatcherEventsRequest, GetWatcherEventsResponse, ListDirRequest, ListDirResponse, MakeDirRequest, MakeDirResponse, MoveRequest, MoveResponse, RemoveRequest, RemoveResponse, RemoveWatcherRequest, RemoveWatcherResponse, StatRequest, StatResponse, WatchDirRequest, WatchDirResponse } from "./filesystem_pb.js"; -import { MethodKind } from "@bufbuild/protobuf"; - -/** - * @generated from service filesystem.Filesystem - */ -export const Filesystem = { - typeName: "filesystem.Filesystem", - methods: { - /** - * @generated from rpc filesystem.Filesystem.Stat - */ - stat: { - name: "Stat", - I: StatRequest, - O: StatResponse, - kind: MethodKind.Unary, - }, - /** - * @generated from rpc filesystem.Filesystem.MakeDir - */ - makeDir: { - name: "MakeDir", - I: MakeDirRequest, - O: MakeDirResponse, - kind: MethodKind.Unary, - }, - /** - * @generated from rpc filesystem.Filesystem.Move - */ - move: { - name: "Move", - I: MoveRequest, - O: MoveResponse, - kind: MethodKind.Unary, - }, - /** - * @generated from rpc filesystem.Filesystem.ListDir - */ - listDir: { - name: "ListDir", - I: ListDirRequest, - O: ListDirResponse, - kind: MethodKind.Unary, - }, - /** - * @generated from rpc filesystem.Filesystem.Remove - */ - remove: { - name: "Remove", - I: RemoveRequest, - O: RemoveResponse, - kind: MethodKind.Unary, - }, - /** - * @generated from rpc filesystem.Filesystem.WatchDir - */ - watchDir: { - name: "WatchDir", - I: WatchDirRequest, - O: WatchDirResponse, - kind: MethodKind.ServerStreaming, - }, - /** - * Non-streaming versions of WatchDir - * - * @generated from rpc filesystem.Filesystem.CreateWatcher - */ - createWatcher: { - name: "CreateWatcher", - I: CreateWatcherRequest, - O: CreateWatcherResponse, - kind: MethodKind.Unary, - }, - /** - * @generated from rpc filesystem.Filesystem.GetWatcherEvents - */ - getWatcherEvents: { - name: "GetWatcherEvents", - I: GetWatcherEventsRequest, - O: GetWatcherEventsResponse, - kind: MethodKind.Unary, - }, - /** - * @generated from rpc filesystem.Filesystem.RemoveWatcher - */ - removeWatcher: { - name: "RemoveWatcher", - I: RemoveWatcherRequest, - O: RemoveWatcherResponse, - kind: MethodKind.Unary, - }, - } -} as const; - diff --git a/src/lib/clients/envd/filesystem/filesystem_pb.ts b/src/lib/clients/envd/filesystem/filesystem_pb.ts deleted file mode 100644 index 120cf3c..0000000 --- a/src/lib/clients/envd/filesystem/filesystem_pb.ts +++ /dev/null @@ -1,573 +0,0 @@ -// @generated by protoc-gen-es v2.5.2 with parameter "target=ts" -// @generated from file filesystem/filesystem.proto (package filesystem, syntax proto3) -/* eslint-disable */ - -import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; -import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; -import type { Message } from "@bufbuild/protobuf"; - -/** - * Describes the file filesystem/filesystem.proto. - */ -export const file_filesystem_filesystem: GenFile = /*@__PURE__*/ - fileDesc("ChtmaWxlc3lzdGVtL2ZpbGVzeXN0ZW0ucHJvdG8SCmZpbGVzeXN0ZW0iMgoLTW92ZVJlcXVlc3QSDgoGc291cmNlGAEgASgJEhMKC2Rlc3RpbmF0aW9uGAIgASgJIjQKDE1vdmVSZXNwb25zZRIkCgVlbnRyeRgBIAEoCzIVLmZpbGVzeXN0ZW0uRW50cnlJbmZvIh4KDk1ha2VEaXJSZXF1ZXN0EgwKBHBhdGgYASABKAkiNwoPTWFrZURpclJlc3BvbnNlEiQKBWVudHJ5GAEgASgLMhUuZmlsZXN5c3RlbS5FbnRyeUluZm8iHQoNUmVtb3ZlUmVxdWVzdBIMCgRwYXRoGAEgASgJIhAKDlJlbW92ZVJlc3BvbnNlIhsKC1N0YXRSZXF1ZXN0EgwKBHBhdGgYASABKAkiNAoMU3RhdFJlc3BvbnNlEiQKBWVudHJ5GAEgASgLMhUuZmlsZXN5c3RlbS5FbnRyeUluZm8iSwoJRW50cnlJbmZvEgwKBG5hbWUYASABKAkSIgoEdHlwZRgCIAEoDjIULmZpbGVzeXN0ZW0uRmlsZVR5cGUSDAoEcGF0aBgDIAEoCSItCg5MaXN0RGlyUmVxdWVzdBIMCgRwYXRoGAEgASgJEg0KBWRlcHRoGAIgASgNIjkKD0xpc3REaXJSZXNwb25zZRImCgdlbnRyaWVzGAEgAygLMhUuZmlsZXN5c3RlbS5FbnRyeUluZm8iMgoPV2F0Y2hEaXJSZXF1ZXN0EgwKBHBhdGgYASABKAkSEQoJcmVjdXJzaXZlGAIgASgIIkQKD0ZpbGVzeXN0ZW1FdmVudBIMCgRuYW1lGAEgASgJEiMKBHR5cGUYAiABKA4yFS5maWxlc3lzdGVtLkV2ZW50VHlwZSLgAQoQV2F0Y2hEaXJSZXNwb25zZRI4CgVzdGFydBgBIAEoCzInLmZpbGVzeXN0ZW0uV2F0Y2hEaXJSZXNwb25zZS5TdGFydEV2ZW50SAASMQoKZmlsZXN5c3RlbRgCIAEoCzIbLmZpbGVzeXN0ZW0uRmlsZXN5c3RlbUV2ZW50SAASOwoJa2VlcGFsaXZlGAMgASgLMiYuZmlsZXN5c3RlbS5XYXRjaERpclJlc3BvbnNlLktlZXBBbGl2ZUgAGgwKClN0YXJ0RXZlbnQaCwoJS2VlcEFsaXZlQgcKBWV2ZW50IjcKFENyZWF0ZVdhdGNoZXJSZXF1ZXN0EgwKBHBhdGgYASABKAkSEQoJcmVjdXJzaXZlGAIgASgIIisKFUNyZWF0ZVdhdGNoZXJSZXNwb25zZRISCgp3YXRjaGVyX2lkGAEgASgJIi0KF0dldFdhdGNoZXJFdmVudHNSZXF1ZXN0EhIKCndhdGNoZXJfaWQYASABKAkiRwoYR2V0V2F0Y2hlckV2ZW50c1Jlc3BvbnNlEisKBmV2ZW50cxgBIAMoCzIbLmZpbGVzeXN0ZW0uRmlsZXN5c3RlbUV2ZW50IioKFFJlbW92ZVdhdGNoZXJSZXF1ZXN0EhIKCndhdGNoZXJfaWQYASABKAkiFwoVUmVtb3ZlV2F0Y2hlclJlc3BvbnNlKlIKCEZpbGVUeXBlEhkKFUZJTEVfVFlQRV9VTlNQRUNJRklFRBAAEhIKDkZJTEVfVFlQRV9GSUxFEAESFwoTRklMRV9UWVBFX0RJUkVDVE9SWRACKpgBCglFdmVudFR5cGUSGgoWRVZFTlRfVFlQRV9VTlNQRUNJRklFRBAAEhUKEUVWRU5UX1RZUEVfQ1JFQVRFEAESFAoQRVZFTlRfVFlQRV9XUklURRACEhUKEUVWRU5UX1RZUEVfUkVNT1ZFEAMSFQoRRVZFTlRfVFlQRV9SRU5BTUUQBBIUChBFVkVOVF9UWVBFX0NITU9EEAUynwUKCkZpbGVzeXN0ZW0SOQoEU3RhdBIXLmZpbGVzeXN0ZW0uU3RhdFJlcXVlc3QaGC5maWxlc3lzdGVtLlN0YXRSZXNwb25zZRJCCgdNYWtlRGlyEhouZmlsZXN5c3RlbS5NYWtlRGlyUmVxdWVzdBobLmZpbGVzeXN0ZW0uTWFrZURpclJlc3BvbnNlEjkKBE1vdmUSFy5maWxlc3lzdGVtLk1vdmVSZXF1ZXN0GhguZmlsZXN5c3RlbS5Nb3ZlUmVzcG9uc2USQgoHTGlzdERpchIaLmZpbGVzeXN0ZW0uTGlzdERpclJlcXVlc3QaGy5maWxlc3lzdGVtLkxpc3REaXJSZXNwb25zZRI/CgZSZW1vdmUSGS5maWxlc3lzdGVtLlJlbW92ZVJlcXVlc3QaGi5maWxlc3lzdGVtLlJlbW92ZVJlc3BvbnNlEkcKCFdhdGNoRGlyEhsuZmlsZXN5c3RlbS5XYXRjaERpclJlcXVlc3QaHC5maWxlc3lzdGVtLldhdGNoRGlyUmVzcG9uc2UwARJUCg1DcmVhdGVXYXRjaGVyEiAuZmlsZXN5c3RlbS5DcmVhdGVXYXRjaGVyUmVxdWVzdBohLmZpbGVzeXN0ZW0uQ3JlYXRlV2F0Y2hlclJlc3BvbnNlEl0KEEdldFdhdGNoZXJFdmVudHMSIy5maWxlc3lzdGVtLkdldFdhdGNoZXJFdmVudHNSZXF1ZXN0GiQuZmlsZXN5c3RlbS5HZXRXYXRjaGVyRXZlbnRzUmVzcG9uc2USVAoNUmVtb3ZlV2F0Y2hlchIgLmZpbGVzeXN0ZW0uUmVtb3ZlV2F0Y2hlclJlcXVlc3QaIS5maWxlc3lzdGVtLlJlbW92ZVdhdGNoZXJSZXNwb25zZUJpCg5jb20uZmlsZXN5c3RlbUIPRmlsZXN5c3RlbVByb3RvUAGiAgNGWFiqAgpGaWxlc3lzdGVtygIKRmlsZXN5c3RlbeICFkZpbGVzeXN0ZW1cR1BCTWV0YWRhdGHqAgpGaWxlc3lzdGVtYgZwcm90bzM"); - -/** - * @generated from message filesystem.MoveRequest - */ -export type MoveRequest = Message<"filesystem.MoveRequest"> & { - /** - * @generated from field: string source = 1; - */ - source: string; - - /** - * @generated from field: string destination = 2; - */ - destination: string; -}; - -/** - * Describes the message filesystem.MoveRequest. - * Use `create(MoveRequestSchema)` to create a new message. - */ -export const MoveRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_filesystem_filesystem, 0); - -/** - * @generated from message filesystem.MoveResponse - */ -export type MoveResponse = Message<"filesystem.MoveResponse"> & { - /** - * @generated from field: filesystem.EntryInfo entry = 1; - */ - entry?: EntryInfo; -}; - -/** - * Describes the message filesystem.MoveResponse. - * Use `create(MoveResponseSchema)` to create a new message. - */ -export const MoveResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_filesystem_filesystem, 1); - -/** - * @generated from message filesystem.MakeDirRequest - */ -export type MakeDirRequest = Message<"filesystem.MakeDirRequest"> & { - /** - * @generated from field: string path = 1; - */ - path: string; -}; - -/** - * Describes the message filesystem.MakeDirRequest. - * Use `create(MakeDirRequestSchema)` to create a new message. - */ -export const MakeDirRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_filesystem_filesystem, 2); - -/** - * @generated from message filesystem.MakeDirResponse - */ -export type MakeDirResponse = Message<"filesystem.MakeDirResponse"> & { - /** - * @generated from field: filesystem.EntryInfo entry = 1; - */ - entry?: EntryInfo; -}; - -/** - * Describes the message filesystem.MakeDirResponse. - * Use `create(MakeDirResponseSchema)` to create a new message. - */ -export const MakeDirResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_filesystem_filesystem, 3); - -/** - * @generated from message filesystem.RemoveRequest - */ -export type RemoveRequest = Message<"filesystem.RemoveRequest"> & { - /** - * @generated from field: string path = 1; - */ - path: string; -}; - -/** - * Describes the message filesystem.RemoveRequest. - * Use `create(RemoveRequestSchema)` to create a new message. - */ -export const RemoveRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_filesystem_filesystem, 4); - -/** - * @generated from message filesystem.RemoveResponse - */ -export type RemoveResponse = Message<"filesystem.RemoveResponse"> & { -}; - -/** - * Describes the message filesystem.RemoveResponse. - * Use `create(RemoveResponseSchema)` to create a new message. - */ -export const RemoveResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_filesystem_filesystem, 5); - -/** - * @generated from message filesystem.StatRequest - */ -export type StatRequest = Message<"filesystem.StatRequest"> & { - /** - * @generated from field: string path = 1; - */ - path: string; -}; - -/** - * Describes the message filesystem.StatRequest. - * Use `create(StatRequestSchema)` to create a new message. - */ -export const StatRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_filesystem_filesystem, 6); - -/** - * @generated from message filesystem.StatResponse - */ -export type StatResponse = Message<"filesystem.StatResponse"> & { - /** - * @generated from field: filesystem.EntryInfo entry = 1; - */ - entry?: EntryInfo; -}; - -/** - * Describes the message filesystem.StatResponse. - * Use `create(StatResponseSchema)` to create a new message. - */ -export const StatResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_filesystem_filesystem, 7); - -/** - * @generated from message filesystem.EntryInfo - */ -export type EntryInfo = Message<"filesystem.EntryInfo"> & { - /** - * @generated from field: string name = 1; - */ - name: string; - - /** - * @generated from field: filesystem.FileType type = 2; - */ - type: FileType; - - /** - * @generated from field: string path = 3; - */ - path: string; -}; - -/** - * Describes the message filesystem.EntryInfo. - * Use `create(EntryInfoSchema)` to create a new message. - */ -export const EntryInfoSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_filesystem_filesystem, 8); - -/** - * @generated from message filesystem.ListDirRequest - */ -export type ListDirRequest = Message<"filesystem.ListDirRequest"> & { - /** - * @generated from field: string path = 1; - */ - path: string; - - /** - * @generated from field: uint32 depth = 2; - */ - depth: number; -}; - -/** - * Describes the message filesystem.ListDirRequest. - * Use `create(ListDirRequestSchema)` to create a new message. - */ -export const ListDirRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_filesystem_filesystem, 9); - -/** - * @generated from message filesystem.ListDirResponse - */ -export type ListDirResponse = Message<"filesystem.ListDirResponse"> & { - /** - * @generated from field: repeated filesystem.EntryInfo entries = 1; - */ - entries: EntryInfo[]; -}; - -/** - * Describes the message filesystem.ListDirResponse. - * Use `create(ListDirResponseSchema)` to create a new message. - */ -export const ListDirResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_filesystem_filesystem, 10); - -/** - * @generated from message filesystem.WatchDirRequest - */ -export type WatchDirRequest = Message<"filesystem.WatchDirRequest"> & { - /** - * @generated from field: string path = 1; - */ - path: string; - - /** - * @generated from field: bool recursive = 2; - */ - recursive: boolean; -}; - -/** - * Describes the message filesystem.WatchDirRequest. - * Use `create(WatchDirRequestSchema)` to create a new message. - */ -export const WatchDirRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_filesystem_filesystem, 11); - -/** - * @generated from message filesystem.FilesystemEvent - */ -export type FilesystemEvent = Message<"filesystem.FilesystemEvent"> & { - /** - * @generated from field: string name = 1; - */ - name: string; - - /** - * @generated from field: filesystem.EventType type = 2; - */ - type: EventType; -}; - -/** - * Describes the message filesystem.FilesystemEvent. - * Use `create(FilesystemEventSchema)` to create a new message. - */ -export const FilesystemEventSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_filesystem_filesystem, 12); - -/** - * @generated from message filesystem.WatchDirResponse - */ -export type WatchDirResponse = Message<"filesystem.WatchDirResponse"> & { - /** - * @generated from oneof filesystem.WatchDirResponse.event - */ - event: { - /** - * @generated from field: filesystem.WatchDirResponse.StartEvent start = 1; - */ - value: WatchDirResponse_StartEvent; - case: "start"; - } | { - /** - * @generated from field: filesystem.FilesystemEvent filesystem = 2; - */ - value: FilesystemEvent; - case: "filesystem"; - } | { - /** - * @generated from field: filesystem.WatchDirResponse.KeepAlive keepalive = 3; - */ - value: WatchDirResponse_KeepAlive; - case: "keepalive"; - } | { case: undefined; value?: undefined }; -}; - -/** - * Describes the message filesystem.WatchDirResponse. - * Use `create(WatchDirResponseSchema)` to create a new message. - */ -export const WatchDirResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_filesystem_filesystem, 13); - -/** - * @generated from message filesystem.WatchDirResponse.StartEvent - */ -export type WatchDirResponse_StartEvent = Message<"filesystem.WatchDirResponse.StartEvent"> & { -}; - -/** - * Describes the message filesystem.WatchDirResponse.StartEvent. - * Use `create(WatchDirResponse_StartEventSchema)` to create a new message. - */ -export const WatchDirResponse_StartEventSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_filesystem_filesystem, 13, 0); - -/** - * @generated from message filesystem.WatchDirResponse.KeepAlive - */ -export type WatchDirResponse_KeepAlive = Message<"filesystem.WatchDirResponse.KeepAlive"> & { -}; - -/** - * Describes the message filesystem.WatchDirResponse.KeepAlive. - * Use `create(WatchDirResponse_KeepAliveSchema)` to create a new message. - */ -export const WatchDirResponse_KeepAliveSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_filesystem_filesystem, 13, 1); - -/** - * @generated from message filesystem.CreateWatcherRequest - */ -export type CreateWatcherRequest = Message<"filesystem.CreateWatcherRequest"> & { - /** - * @generated from field: string path = 1; - */ - path: string; - - /** - * @generated from field: bool recursive = 2; - */ - recursive: boolean; -}; - -/** - * Describes the message filesystem.CreateWatcherRequest. - * Use `create(CreateWatcherRequestSchema)` to create a new message. - */ -export const CreateWatcherRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_filesystem_filesystem, 14); - -/** - * @generated from message filesystem.CreateWatcherResponse - */ -export type CreateWatcherResponse = Message<"filesystem.CreateWatcherResponse"> & { - /** - * @generated from field: string watcher_id = 1; - */ - watcherId: string; -}; - -/** - * Describes the message filesystem.CreateWatcherResponse. - * Use `create(CreateWatcherResponseSchema)` to create a new message. - */ -export const CreateWatcherResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_filesystem_filesystem, 15); - -/** - * @generated from message filesystem.GetWatcherEventsRequest - */ -export type GetWatcherEventsRequest = Message<"filesystem.GetWatcherEventsRequest"> & { - /** - * @generated from field: string watcher_id = 1; - */ - watcherId: string; -}; - -/** - * Describes the message filesystem.GetWatcherEventsRequest. - * Use `create(GetWatcherEventsRequestSchema)` to create a new message. - */ -export const GetWatcherEventsRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_filesystem_filesystem, 16); - -/** - * @generated from message filesystem.GetWatcherEventsResponse - */ -export type GetWatcherEventsResponse = Message<"filesystem.GetWatcherEventsResponse"> & { - /** - * @generated from field: repeated filesystem.FilesystemEvent events = 1; - */ - events: FilesystemEvent[]; -}; - -/** - * Describes the message filesystem.GetWatcherEventsResponse. - * Use `create(GetWatcherEventsResponseSchema)` to create a new message. - */ -export const GetWatcherEventsResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_filesystem_filesystem, 17); - -/** - * @generated from message filesystem.RemoveWatcherRequest - */ -export type RemoveWatcherRequest = Message<"filesystem.RemoveWatcherRequest"> & { - /** - * @generated from field: string watcher_id = 1; - */ - watcherId: string; -}; - -/** - * Describes the message filesystem.RemoveWatcherRequest. - * Use `create(RemoveWatcherRequestSchema)` to create a new message. - */ -export const RemoveWatcherRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_filesystem_filesystem, 18); - -/** - * @generated from message filesystem.RemoveWatcherResponse - */ -export type RemoveWatcherResponse = Message<"filesystem.RemoveWatcherResponse"> & { -}; - -/** - * Describes the message filesystem.RemoveWatcherResponse. - * Use `create(RemoveWatcherResponseSchema)` to create a new message. - */ -export const RemoveWatcherResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_filesystem_filesystem, 19); - -/** - * @generated from enum filesystem.FileType - */ -export enum FileType { - /** - * @generated from enum value: FILE_TYPE_UNSPECIFIED = 0; - */ - UNSPECIFIED = 0, - - /** - * @generated from enum value: FILE_TYPE_FILE = 1; - */ - FILE = 1, - - /** - * @generated from enum value: FILE_TYPE_DIRECTORY = 2; - */ - DIRECTORY = 2, -} - -/** - * Describes the enum filesystem.FileType. - */ -export const FileTypeSchema: GenEnum = /*@__PURE__*/ - enumDesc(file_filesystem_filesystem, 0); - -/** - * @generated from enum filesystem.EventType - */ -export enum EventType { - /** - * @generated from enum value: EVENT_TYPE_UNSPECIFIED = 0; - */ - UNSPECIFIED = 0, - - /** - * @generated from enum value: EVENT_TYPE_CREATE = 1; - */ - CREATE = 1, - - /** - * @generated from enum value: EVENT_TYPE_WRITE = 2; - */ - WRITE = 2, - - /** - * @generated from enum value: EVENT_TYPE_REMOVE = 3; - */ - REMOVE = 3, - - /** - * @generated from enum value: EVENT_TYPE_RENAME = 4; - */ - RENAME = 4, - - /** - * @generated from enum value: EVENT_TYPE_CHMOD = 5; - */ - CHMOD = 5, -} - -/** - * Describes the enum filesystem.EventType. - */ -export const EventTypeSchema: GenEnum = /*@__PURE__*/ - enumDesc(file_filesystem_filesystem, 1); - -/** - * @generated from service filesystem.Filesystem - */ -export const Filesystem: GenService<{ - /** - * @generated from rpc filesystem.Filesystem.Stat - */ - stat: { - methodKind: "unary"; - input: typeof StatRequestSchema; - output: typeof StatResponseSchema; - }, - /** - * @generated from rpc filesystem.Filesystem.MakeDir - */ - makeDir: { - methodKind: "unary"; - input: typeof MakeDirRequestSchema; - output: typeof MakeDirResponseSchema; - }, - /** - * @generated from rpc filesystem.Filesystem.Move - */ - move: { - methodKind: "unary"; - input: typeof MoveRequestSchema; - output: typeof MoveResponseSchema; - }, - /** - * @generated from rpc filesystem.Filesystem.ListDir - */ - listDir: { - methodKind: "unary"; - input: typeof ListDirRequestSchema; - output: typeof ListDirResponseSchema; - }, - /** - * @generated from rpc filesystem.Filesystem.Remove - */ - remove: { - methodKind: "unary"; - input: typeof RemoveRequestSchema; - output: typeof RemoveResponseSchema; - }, - /** - * @generated from rpc filesystem.Filesystem.WatchDir - */ - watchDir: { - methodKind: "server_streaming"; - input: typeof WatchDirRequestSchema; - output: typeof WatchDirResponseSchema; - }, - /** - * Non-streaming versions of WatchDir - * - * @generated from rpc filesystem.Filesystem.CreateWatcher - */ - createWatcher: { - methodKind: "unary"; - input: typeof CreateWatcherRequestSchema; - output: typeof CreateWatcherResponseSchema; - }, - /** - * @generated from rpc filesystem.Filesystem.GetWatcherEvents - */ - getWatcherEvents: { - methodKind: "unary"; - input: typeof GetWatcherEventsRequestSchema; - output: typeof GetWatcherEventsResponseSchema; - }, - /** - * @generated from rpc filesystem.Filesystem.RemoveWatcher - */ - removeWatcher: { - methodKind: "unary"; - input: typeof RemoveWatcherRequestSchema; - output: typeof RemoveWatcherResponseSchema; - }, -}> = /*@__PURE__*/ - serviceDesc(file_filesystem_filesystem, 0); - diff --git a/src/lib/clients/sandbox-pool.ts b/src/lib/clients/sandbox-pool.ts new file mode 100644 index 0000000..b6461d4 --- /dev/null +++ b/src/lib/clients/sandbox-pool.ts @@ -0,0 +1,111 @@ +import 'server-cli-only' + +import { Sandbox, type SandboxOpts } from 'e2b' +import { VERBOSE } from '@/configs/flags' +import { logDebug } from './logger' + +/** + * How long we keep the connection alive after the last consumer released it. + * A short grace period avoids connect/disconnect thrashing when the browser + * refreshes or multiple API calls arrive in quick succession. + */ +const GRACE_MS = 10_000 + +interface Entry { + /** Pending or resolved connect promise */ + promise: Promise + /** Resolved sandbox instance (set after promise fulfils) */ + sandbox?: Sandbox + /** Number of active users of this connection */ + ref: number + /** Handle for delayed close */ + timer?: ReturnType +} + +// --------------------------------------------- +// Global singleton (per Node) +// --------------------------------------------- +// eslint-disable-next-line no-var +declare global { + // `var` is required for global augmentation – suppressed for eslint + // eslint-disable-next-line no-var + var __SBX_POOL: Map | undefined +} + +const POOL: Map = (globalThis.__SBX_POOL ??= new Map< + string, + Entry +>()) + +export class SandboxPool { + /** + * Acquire (or create) a shared sandbox connection for `sandboxId`. + * Each caller MUST call `release()` when finished. + */ + static async acquire( + sandboxId: string, + opts: SandboxOpts + ): Promise { + let entry = POOL.get(sandboxId) + + if (entry) { + entry.ref += 1 + clearTimeout(entry.timer) + if (VERBOSE) + logDebug('SandboxPool.acquire reuse', sandboxId, 'refs', entry.ref) + } else { + if (VERBOSE) logDebug('SandboxPool.acquire connect', sandboxId) + const promise = Sandbox.connect(sandboxId, opts) as Promise + entry = { promise, ref: 1 } + POOL.set(sandboxId, entry) + + // Cache resolved instance, drop entry if connect fails + promise + .then((sbx) => { + entry!.sandbox = sbx + if (VERBOSE) logDebug('SandboxPool connected', sandboxId) + }) + .catch((err) => { + if (VERBOSE) logDebug('SandboxPool connect FAILED', sandboxId, err) + POOL.delete(sandboxId) + }) + } + + if (VERBOSE) logDebug('SandboxPool.acquire return', sandboxId, 'promise') + return entry.promise as Promise + } + + /** + * Release one reference obtained via `acquire()`. The connection is closed + * after `GRACE_MS` when no other consumers remain. + */ + static async release(sandboxId: string): Promise { + const entry = POOL.get(sandboxId) + if (!entry) return + + entry.ref = Math.max(0, entry.ref - 1) + + if (VERBOSE) logDebug('SandboxPool.release', sandboxId, 'refs', entry.ref) + + if (entry.ref === 0 && !entry.timer) { + if (VERBOSE) + logDebug('SandboxPool schedule close', sandboxId, `in ${GRACE_MS}ms`) + entry.timer = setTimeout(async () => { + if (entry.ref === 0) { + if (VERBOSE) logDebug('SandboxPool closing', sandboxId) + try { + const closable = entry.sandbox as unknown as { + close?: () => Promise + dispose?: () => Promise + } + if (closable?.close) await closable.close() + else if (closable?.dispose) await closable.dispose() + } finally { + POOL.delete(sandboxId) + if (VERBOSE) logDebug('SandboxPool closed', sandboxId) + } + } + }, GRACE_MS) + } + } +} diff --git a/src/lib/clients/watch-dir-pool.ts b/src/lib/clients/watch-dir-pool.ts new file mode 100644 index 0000000..961220d --- /dev/null +++ b/src/lib/clients/watch-dir-pool.ts @@ -0,0 +1,125 @@ +import 'server-cli-only' + +import { WatchHandle, FilesystemEvent } from 'e2b' +import { SandboxPool } from './sandbox-pool' +import { VERBOSE } from '@/configs/flags' +import { logDebug } from './logger' + +// Grace period in milliseconds before cleaning up unused watch handles – +// 30 s gives background tabs enough time to reconnect after throttling. +const GRACE_MS = 30_000 + +interface Entry { + // Promise that resolves to the watch handle once created + promise: Promise + // The actual watch handle once available + handle?: WatchHandle + // Set of callback functions from all consumers watching this directory + consumers: Set<(e: FilesystemEvent) => void> + // Reference count of active consumers + ref: number + // Timer for cleanup when ref count reaches 0 + timer?: ReturnType +} + +// --------------------------------------------- +// Global singleton (per Node) +// --------------------------------------------- +// eslint-disable-next-line no-var +declare global { + // `var` is required for global augmentation – suppressed for eslint + // eslint-disable-next-line no-var + var __WATCH_POOL: Map | undefined +} + +const POOL: Map = (globalThis.__WATCH_POOL ??= new Map< + string, + Entry +>()) + +function makeKey(sandboxId: string, dir: string) { + return `${sandboxId}:${dir}` +} + +export class WatchDirPool { + /** + * Acquire (or create) a shared WatchHandle. Multiple callers are + * fanned-out via an internal consumer list—no mutation of the SDK types. + */ + static async acquire( + sandboxId: string, + dir: string, + onEvent: (ev: FilesystemEvent) => void, + sandboxOpts: Parameters[1] + ): Promise { + const key = makeKey(sandboxId, dir) + let entry = POOL.get(key) + + if (VERBOSE) logDebug('WatchDirPool.acquire', key) + + if (entry) { + entry.ref += 1 + entry.consumers.add(onEvent) + clearTimeout(entry.timer) + if (VERBOSE) logDebug('WatchDirPool reuse', key, 'refs', entry.ref) + } else { + if (VERBOSE) logDebug('WatchDirPool create watcher', key) + entry = { + ref: 1, + consumers: new Set([onEvent as (ev: FilesystemEvent) => void]), + promise: (async () => { + const sbx = await SandboxPool.acquire(sandboxId, sandboxOpts) + if (VERBOSE) + logDebug('WatchDirPool connected to sandbox', sandboxId, 'dir', dir) + const handle = await sbx.files.watchDir( + dir, + (ev) => entry!.consumers.forEach((fn) => fn(ev)), + { recursive: true } + ) + entry!.handle = handle + if (VERBOSE) logDebug('WatchDirPool watcher ready', key) + return handle + })(), + } + POOL.set(key, entry) + } + + return entry.promise + } + + /** + * Release one reference. When the last reference is gone the underlying + * stream is closed after GRACE_MS. + */ + static async release( + sandboxId: string, + dir: string, + onEvent: (ev: FilesystemEvent) => void + ): Promise { + const key = makeKey(sandboxId, dir) + const entry = POOL.get(key) + if (!entry) return + + entry.ref = Math.max(0, entry.ref - 1) + entry.consumers.delete(onEvent) + + if (VERBOSE) logDebug('WatchDirPool.release', key, 'refs', entry.ref) + + if (entry.ref === 0 && !entry.timer) { + if (VERBOSE) + logDebug('WatchDirPool schedule stop', key, `in ${GRACE_MS}ms`) + entry.timer = setTimeout(async () => { + if (entry.ref === 0) { + if (VERBOSE) logDebug('WatchDirPool stopping', key) + try { + await entry.handle?.stop() + await SandboxPool.release(sandboxId) + } finally { + POOL.delete(key) + if (VERBOSE) logDebug('WatchDirPool stopped', key) + } + } + }, GRACE_MS) + } + } +} diff --git a/src/lib/env.ts b/src/lib/env.ts index 8b92f75..13b2468 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -5,6 +5,7 @@ export const serverSchema = z.object({ INFRA_API_URL: z.string().url(), KV_REST_API_TOKEN: z.string().min(1), KV_REST_API_URL: z.string().url(), + E2B_DOMAIN: z.string(), BILLING_API_URL: z.string().url().optional(), ZEROBOUNCE_API_KEY: z.string().optional(), diff --git a/src/lib/hooks/use-teams.ts b/src/lib/hooks/use-teams.ts index bc6faf6..67e0eb4 100644 --- a/src/lib/hooks/use-teams.ts +++ b/src/lib/hooks/use-teams.ts @@ -1,4 +1,4 @@ -import { useServerContext } from './use-server-context' +import { useServerContext } from '../../features/dashboard/server-context' export const useTeams = () => { const { teams } = useServerContext() diff --git a/src/lib/hooks/use-user.ts b/src/lib/hooks/use-user.ts index e470b3f..6ee8baa 100644 --- a/src/lib/hooks/use-user.ts +++ b/src/lib/hooks/use-user.ts @@ -1,6 +1,6 @@ 'use client' -import { useServerContext } from './use-server-context' +import { useServerContext } from '../../features/dashboard/server-context' export const useUser = () => { const { user } = useServerContext() diff --git a/src/lib/utils/filesystem.ts b/src/lib/utils/filesystem.ts new file mode 100644 index 0000000..7936ef9 --- /dev/null +++ b/src/lib/utils/filesystem.ts @@ -0,0 +1,105 @@ +/** + * Normalize a path by removing duplicate slashes and resolving . and .. segments + */ +export function normalizePath(path: string): string { + // Handle empty path + if (!path || path === '') return '/' + + // Ensure path starts with / + if (!path.startsWith('/')) { + path = '/' + path + } + + // Split path into segments + const segments = path + .split('/') + .filter((segment) => segment !== '' && segment !== '.') + const normalized: string[] = [] + + for (const segment of segments) { + if (segment === '..') { + // Pop the last segment if we have one (don't go above root) + if (normalized.length > 0) { + normalized.pop() + } + } else { + normalized.push(segment) + } + } + + // Join segments back together + const result = '/' + normalized.join('/') + + // Ensure we don't return empty string, always at least '/' + return result === '' ? '/' : result +} + +/** + * Get the parent directory of a path + */ +export function getParentPath(path: string): string { + const normalized = normalizePath(path) + if (normalized === '/') return '/' + + const lastSlashIndex = normalized.lastIndexOf('/') + if (lastSlashIndex === 0) return '/' + + return normalized.substring(0, lastSlashIndex) +} + +/** + * Get the basename (filename) of a path + */ +export function getBasename(path: string): string { + const normalized = normalizePath(path) + if (normalized === '/') return '/' + + const lastSlashIndex = normalized.lastIndexOf('/') + return normalized.substring(lastSlashIndex + 1) +} + +/** + * Join path segments together + */ +export function joinPath(...segments: string[]): string { + if (segments.length === 0) return '/' + + const joined = segments + .filter((segment) => segment !== '' && segment != null) + .join('/') + + return normalizePath(joined) +} + +/** + * Check if a path is a child of another path + */ +export function isChildPath(parentPath: string, childPath: string): boolean { + const normalizedParent = normalizePath(parentPath) + const normalizedChild = normalizePath(childPath) + + if (normalizedParent === normalizedChild) return false + + // Ensure parent ends with / for proper comparison + const parentWithSlash = + normalizedParent === '/' ? '/' : normalizedParent + '/' + + return normalizedChild.startsWith(parentWithSlash) +} + +/** + * Get the depth of a path (number of directory levels) + */ +export function getPathDepth(path: string): number { + const normalized = normalizePath(path) + if (normalized === '/') return 0 + + return normalized.split('/').length - 1 +} + +/** + * Check if a path is the root path + */ +export function isRootPath(path: string): boolean { + return normalizePath(path) === '/' +} diff --git a/src/server/sandboxes/get-sandbox-root.ts b/src/server/sandboxes/get-sandbox-root.ts new file mode 100644 index 0000000..af83ad7 --- /dev/null +++ b/src/server/sandboxes/get-sandbox-root.ts @@ -0,0 +1,51 @@ +// src/server/sandboxes/get-sandbox-root.ts +import { z } from 'zod' +import { authActionClient } from '@/lib/clients/action' +import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { ERROR_CODES } from '@/configs/logs' +import { logError } from '@/lib/clients/logger' +import { returnServerError } from '@/lib/utils/action' +import { SandboxPool } from '@/lib/clients/sandbox-pool' +import { FsFileType } from '@/types/filesystem' +import { FileType } from 'e2b' + +export const GetSandboxRootSchema = z.object({ + teamId: z.string().uuid(), + sandboxId: z.string(), + rootPath: z.string().default('/'), +}) + +export const getSandboxRoot = authActionClient + .schema(GetSandboxRootSchema) + .metadata({ serverFunctionName: 'getSandboxRoot' }) + .action(async ({ parsedInput, ctx }) => { + const { teamId, sandboxId, rootPath } = parsedInput + const { session } = ctx + + const headers = SUPABASE_AUTH_HEADERS(session.access_token, teamId) + + let entries + try { + const sandbox = await SandboxPool.acquire(sandboxId, { + headers, + }) + const raw = await sandbox.files.list(rootPath) + entries = raw.map((e) => ({ + name: e.name, + path: e.path, + type: + e.type === FileType.DIR + ? ('dir' as FsFileType) + : ('file' as FsFileType), + })) + } catch (err) { + logError(ERROR_CODES.INFRA, 'files.list', 500, err) + return returnServerError('Failed to list sandbox directory.') + } finally { + await SandboxPool.release(sandboxId) + } + + return { + entries, + } + }) diff --git a/src/types/api.d.ts b/src/types/api.d.ts index 1f742bc..527f67a 100644 --- a/src/types/api.d.ts +++ b/src/types/api.d.ts @@ -5,6 +5,8 @@ import { type Sandbox = InfraComponents['schemas']['ListedSandbox'] +type SandboxInfo = InfraComponents['schemas']['SandboxDetail'] + type Template = InfraComponents['schemas']['Template'] type DefaultTemplate = Template & { @@ -27,6 +29,7 @@ type TeamAPIKey = InfraComponents['schemas']['TeamAPIKey'] export type { Sandbox, + SandboxInfo, Template, SandboxMetrics, DefaultTemplate, diff --git a/src/types/filesystem.ts b/src/types/filesystem.ts new file mode 100644 index 0000000..cd6caec --- /dev/null +++ b/src/types/filesystem.ts @@ -0,0 +1,17 @@ +// NOTE: We need to maintain duplicate types of the e2b sdk, in order to avoid having the whole sdk inside the client bundle. +// The issue here mainly stems from the FileType and FilesystemEvent enums. + +export type FsFileType = 'file' | 'dir' + +export interface FsEntry { + name: string + path: string + type: FsFileType +} + +export type FsEventType = 'create' | 'write' | 'remove' | 'rename' | 'chmod' + +export interface FsEvent { + name: string + type: FsEventType +} diff --git a/tsconfig.json b/tsconfig.json index 211eb95..1f5c78a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "ES2020", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -12,7 +16,6 @@ "moduleResolution": "bundler", "noUncheckedIndexedAccess": true, "resolveJsonModule": true, - "isolatedModules": true, "jsx": "preserve", "incremental": true, "plugins": [ @@ -21,9 +24,18 @@ } ], "paths": { - "@/*": ["./src/*"] - } + "@/*": [ + "./src/*" + ] + }, + "isolatedModules": true }, - "include": ["next-env.d.ts", "src", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "include": [ + "next-env.d.ts", + "src", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] }