├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .gitpod.Dockerfile ├── .gitpod.yml ├── .husky └── pre-commit ├── .lintstagedrc.js ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── prettier.config.js ├── rollup.config.js ├── shared ├── README.md └── api │ └── openapi.yml ├── src ├── api │ ├── cross-fetch-polyfill.d.ts │ ├── index.ts │ └── schema.gen.ts ├── constants.ts ├── index.ts ├── session │ ├── codeSnippet.ts │ ├── envVars.ts │ ├── filesystem.ts │ ├── filesystemWatcher.ts │ ├── index.ts │ ├── out.ts │ ├── process.ts │ ├── sessionConnection.ts │ └── terminal.ts └── utils │ ├── id.ts │ ├── logger.ts │ ├── promise.ts │ └── wait.ts ├── test ├── performance.mjs └── run.mjs └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | src/api/schema.gen.ts 2 | dist/** 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true, 6 | }, 7 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'], 8 | parser: '@typescript-eslint/parser', 9 | parserOptions: { 10 | ecmaVersion: 'latest', 11 | sourceType: 'module', 12 | }, 13 | plugins: ['@typescript-eslint', 'prettier', 'unused-imports'], 14 | rules: { 15 | '@typescript-eslint/member-ordering': ['error'], 16 | 'linebreak-style': ['error', 'unix'], 17 | 'max-len': [ 18 | 'error', 19 | { 20 | code: 90, 21 | ignoreComments: true, 22 | ignoreStrings: true, 23 | ignoreTemplateLiterals: true, 24 | }, 25 | ], 26 | 'prettier/prettier': ['error'], 27 | quotes: ['error', 'single', { avoidEscape: true }], 28 | semi: ['error', 'never'], 29 | 'unused-imports/no-unused-imports': 'error', 30 | 'unused-imports/no-unused-vars': [ 31 | 'warn', 32 | { 33 | args: 'none', 34 | argsIgnorePattern: '^_', 35 | vars: 'all', 36 | varsIgnorePattern: '^_', 37 | }, 38 | ], 39 | }, 40 | } 41 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish NPM Package 2 | 3 | on: 4 | push: 5 | tags: [v*] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-20.04 10 | steps: 11 | - uses: actions/checkout@v2 12 | # Setup .npmrc file to publish to npm 13 | - uses: actions/setup-node@v2 14 | with: 15 | node-version: '16.x' 16 | registry-url: 'https://registry.npmjs.org' 17 | - run: npm install 18 | - run: npm publish --access=public 19 | env: 20 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 21 | - name: Release 22 | uses: softprops/action-gh-release@v1 23 | with: 24 | generate_release_notes: true 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | 22 | # Optional npm cache directory 23 | .npm 24 | 25 | # Optional eslint cache 26 | .eslintcache 27 | 28 | # Output of 'npm pack' 29 | *.tgz 30 | 31 | # Yarn Integrity file 32 | .yarn-integrity 33 | 34 | # generate output 35 | dist 36 | 37 | # compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | /test.md 41 | logs.txt -------------------------------------------------------------------------------- /.gitpod.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gitpod/workspace-full:latest 2 | 3 | ENV DEBIAN_FRONTEND=noninteractive 4 | 5 | RUN sudo apt-get update \ 6 | && sudo apt-get install -y tmux \ 7 | && sudo apt-get install -y neovim \ 8 | && sudo rm -rf /var/lib/apt/lists/* 9 | 10 | RUN brew install fzf 11 | # vim-plug 12 | RUN bash -c 'curl -fLo "${XDG_DATA_HOME:-$HOME/.local/share}"/nvim/site/autoload/plug.vim --create-dirs https://raw.githubusercontent.com/junegunn/vim-plug/master/plug.vim' 13 | 14 | RUN mkdir -p ~/.config/nvim/ 15 | # Download rcfiles 16 | RUN curl https://raw.githubusercontent.com/mlejva/rcfiles/master/init.vim --output ~/.config/nvim/init.vim 17 | RUN curl https://raw.githubusercontent.com/mlejva/rcfiles/master/tmux.conf --output ~/.tmux.conf 18 | 19 | RUN bash -c ". .nvm/nvm.sh && nvm install 16.4.0 && nvm use 16.4.0 && nvm alias default 16.4.0" 20 | 21 | RUN echo "nvm use default &>/dev/null" >> ~/.bashrc.d/51-nvm-fix 22 | 23 | RUN npm i depcheck npm-check-updates -g 24 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | tasks: 2 | - name: Setup 3 | init: npm i 4 | 5 | vscode: 6 | extensions: 7 | - dbaeumer.vscode-eslint 8 | 9 | github: 10 | prebuilds: 11 | branches: true 12 | 13 | image: 14 | file: .gitpod.Dockerfile 15 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged || true 5 | -------------------------------------------------------------------------------- /.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | const buildEslintCommand = filenames => 4 | `eslint --fix ${filenames.map(f => path.relative(process.cwd(), f)).join(' ')}` 5 | 6 | module.exports = { 7 | '*.{js,jsx,ts,tsx}': [buildEslintCommand], 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "search.exclude": { 3 | "**/.git": true, 4 | "**/.svn": true, 5 | "**/.hg": true, 6 | "**/CVS": true, 7 | "**/.DS_Store": true, 8 | "lib/**": true, 9 | ".next/**": true, 10 | ".vscode/**": true, 11 | ".git/**": true, 12 | "node_modules/**": true, 13 | "package-lock.json": true, 14 | "yarl.lock": true 15 | }, 16 | "files.watcherExclude": { 17 | "**/.git": true, 18 | "**/.svn": true, 19 | "**/.hg": true, 20 | "**/CVS": true, 21 | "**/.DS_Store": true, 22 | "lib/**": true, 23 | ".next/**": true, 24 | ".vscode/**": true, 25 | ".git/**": true, 26 | "node_modules/**": true, 27 | "package-lock.json": true, 28 | "yarl.lock": true 29 | }, 30 | "typescript.tsdk": "node_modules/typescript/lib", 31 | "editor.quickSuggestions": { 32 | "strings": true 33 | }, 34 | "editor.formatOnSave": false, 35 | "editor.codeActionsOnSave": { 36 | "source.fixAll.eslint": true 37 | }, 38 | "eslint.validate": ["javascript", "typescript"] 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Business Source License 1.1 2 | 3 | Parameters 4 | 5 | Licensor: FoundryLabs, Inc. 6 | Licensed Work: Devbook SDK 7 | The Licensed Work is (c) 2022 FoundryLabs, Inc. 8 | Additional Use Grant: 9 | 10 | Change Date: 2026-01-19 11 | 12 | Change License: Apache License, Version 2.0 13 | 14 | Notice 15 | 16 | The Business Source License (this document, or the "License") is not an Open 17 | Source license. However, the Licensed Work will eventually be made available 18 | under an Open Source License, as stated in this License. 19 | 20 | License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. 21 | "Business Source License" is a trademark of MariaDB Corporation Ab. 22 | 23 | ----------------------------------------------------------------------------- 24 | 25 | Business Source License 1.1 26 | 27 | Terms 28 | 29 | The Licensor hereby grants you the right to copy, modify, create derivative 30 | works, redistribute, and make non-production use of the Licensed Work. The 31 | Licensor may make an Additional Use Grant, above, permitting limited 32 | production use. 33 | 34 | Effective on the Change Date, or the fourth anniversary of the first publicly 35 | available distribution of a specific version of the Licensed Work under this 36 | License, whichever comes first, the Licensor hereby grants you rights under 37 | the terms of the Change License, and the rights granted in the paragraph 38 | above terminate. 39 | 40 | If your use of the Licensed Work does not comply with the requirements 41 | currently in effect as described in this License, you must purchase a 42 | commercial license from the Licensor, its affiliated entities, or authorized 43 | resellers, or you must refrain from using the Licensed Work. 44 | 45 | All copies of the original and modified Licensed Work, and derivative works 46 | of the Licensed Work, are subject to this License. This License applies 47 | separately for each version of the Licensed Work and the Change Date may vary 48 | for each version of the Licensed Work released by Licensor. 49 | 50 | You must conspicuously display this License on each original or modified copy 51 | of the Licensed Work. If you receive the Licensed Work in original or 52 | modified form from a third party, the terms and conditions set forth in this 53 | License apply to your use of that work. 54 | 55 | Any use of the Licensed Work in violation of this License will automatically 56 | terminate your rights under this License for the current and all other 57 | versions of the Licensed Work. 58 | 59 | This License does not grant you any right in any trademark or logo of 60 | Licensor or its affiliates (provided that you may use a trademark or logo of 61 | Licensor as expressly required by this License). 62 | 63 | TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON 64 | AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, 65 | EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF 66 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND 67 | TITLE. 68 | 69 | MariaDB hereby grants you permission to use this License’s text to license 70 | your works, and to refer to it using the trademark "Business Source License", 71 | as long as you comply with the Covenants of Licensor below. 72 | 73 | Covenants of Licensor 74 | 75 | In consideration of the right to use this License’s text and the "Business 76 | Source License" name and trademark, Licensor covenants to MariaDB, and to all 77 | other recipients of the licensed work to be provided by Licensor: 78 | 79 | 1. To specify as the Change License the GPL Version 2.0 or any later version, 80 | or a license that is compatible with GPL Version 2.0 or a later version, 81 | where "compatible" means that software provided under the Change License can 82 | be included in a program with software provided under GPL Version 2.0 or a 83 | later version. Licensor may specify additional Change Licenses without 84 | limitation. 85 | 86 | 2. To either: (a) specify an additional grant of rights to use that does not 87 | impose any additional restriction on the right granted in this License, as 88 | the Additional Use Grant; or (b) insert the text "None". 89 | 90 | 3. To specify a Change Date. 91 | 92 | 4. Not to modify this License in any other way. 93 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Devbook SDK 2 | 3 | SDK for managing Devbook sessions from JavaScript/TypeScript. Devbook SDK requires [`devbookd`](https://github.com/devbookhq/devbookd) running on the server to which it's connecting. 4 | 5 | ## Installation 6 | 7 | ```sh 8 | npm install @devbookhq/sdk 9 | ``` 10 | 11 | or 12 | 13 | ```sh 14 | yarn add @devbookhq/sdk 15 | ``` 16 | 17 | ## Usage 18 | 19 | ### Open a new session 20 | 21 | You **start a new session** by creating a `Session` instance and calling the `session.open` method. 22 | 23 | `` is the ID of the environment from Devbook backend. 24 | 25 | When creating the `Session` you can **register handlers for various session events** by passing the handlers to the `Session` constructor. 26 | 27 | You can **manually close** the session by calling `session.close`. If you need to open the session again after calling `session.close` you have to create a new `Session` object and call `session.open` on it. 28 | 29 | ```ts 30 | import { Session } from '@devbookhq/sdk' 31 | 32 | const session = new Session({ 33 | id: '', 34 | // Options for connection to a special session with persistent changes 35 | editEnabled: false, 36 | apiKey: undefined, 37 | // Event handlers 38 | codeSnippet: { 39 | onStateChange: state => console.log(state), 40 | onStderr: stderr => console.log(stderr), 41 | onStdout: stdout => console.log(stdout), 42 | }, 43 | onDisconnect: () => console.log('disconnect'), 44 | onReconnect: () => console.log('reconnect'), 45 | onClose: () => console.log('close'), 46 | }) 47 | 48 | await session.open() 49 | 50 | // If you don't need the session anymore: 51 | await session.close() 52 | ``` 53 | 54 | > You shall not call any other methods on the `session` object before the `session.open` finishes. Before this method successfully finishes you are **not** connected to the actual session and the fields `session.codeSnippet`, `session.terminal`, `session.filesystem`, and `session.process` are `undefined`. 55 | 56 | ### Run code snippet 57 | 58 | You can **run arbitrary code** with the runtime predefined in the Devbook env by calling `session.codeSnippet.run`. 59 | 60 | You receive the `stderr`, `stdout`, and the information about the code execution from the `onStderr`, `onStdout`, and `onStateChange` handlers that you can pass to the `Session` constructor inside the `codeSnippet` object. 61 | 62 | There can be only **one running code snippet at the same time** — you can stop the one that is currently running by calling `session.codeSnippet.stop`. 63 | 64 | ```ts 65 | await session.codeSnippet.run('echo 2') 66 | 67 | await session.codeSnippet.stop() 68 | ``` 69 | 70 | ### Interact with the filesystem 71 | 72 | Following filesystem operations are supported. 73 | 74 | - **`list`** 75 | 76 | Lists content of a directory. 77 | ```ts 78 | const dirBContent = await session.filesystem.list('/dirA/dirB') 79 | ``` 80 | 81 | - **`write`** 82 | 83 | Writes content to a new file. 84 | ```ts 85 | // This will create a new file 'file.txt' inside the dir 'dirB' with the content 'Hello world'. 86 | await session.filesystem.write('/dirA/dirB/file.txt', 'Hello World') 87 | ``` 88 | 89 | - **`read`** 90 | 91 | Reads content of a file. 92 | ```ts 93 | const fileContent = await session.filesystem.read('/dirA/dirB/file.txt') 94 | ``` 95 | 96 | - **`remove`** 97 | 98 | Removes a file or a directory. 99 | ```ts 100 | // Remove a file. 101 | await session.filesystem.remove('/dirA/dirB/file.txt') 102 | 103 | // Remove a directory and all of its content. 104 | await session.filesystem.remove('/dirA') 105 | ``` 106 | 107 | - **`makeDir`** 108 | 109 | Creates a new directory and all directories along the way if needed. 110 | ```ts 111 | // Creates a new directory 'dirC' and also 'dirA' and 'dirB' if those directories don't already exist. 112 | await session.filesystem.makeDir('/dirA/dirB/dirC') 113 | ``` 114 | 115 | - **`watchDir`** 116 | 117 | Watches a directory for filesystem events. 118 | ```ts 119 | const watcher = session.filesystem.watchDir('/dirA/dirB') 120 | watcher.addEventListener(fsevent => { 121 | console.log('Change inside the dirB', fsevent) 122 | }) 123 | await watcher.start() 124 | ``` 125 | 126 | ### Start a terminal session 127 | 128 | You can **start a new terminal** in the session by calling `session.terminal.createSession`. 129 | 130 | > If you want to connect to the same terminal when you reconnect to a session you can use the `terminalID` option when creating the terminal. This is currently used for debugging purposes and when you connect to a special persistent session (`editEnabled` option when creating a new `Session`). 131 | 132 | > If you are using frontend terminal component like [Xtermjs](https://github.com/xtermjs/xterm.js/) you want to pass the data from `onData` handler to Xtermjs and forward the data from Xtermjs to the `term.sendData` method. 133 | 134 | If you start any **child processes in the terminal** you can use the `onChildProcessesChange` handler and see when they start and exit. You can **kill** the child processes with `session.terminal.killProcess` method. 135 | 136 | You can **manually destroy** the terminal by calling `term.destroy`. 137 | 138 | ```ts 139 | const term = await session.terminal.createSession({ 140 | onExit: () => console.log, 141 | onData: (data) => console.log(data), 142 | onChildProcessesChange?: (cps) => console.log(cps), 143 | size: { cols: 10, rows: 20 }, 144 | terminalID: '', 145 | }) 146 | 147 | await term.destroy() 148 | 149 | await term.resize({ cols: 1, rows: 1}) 150 | 151 | await term.sendData('\n') 152 | 153 | console.log(term.terminalID) 154 | 155 | await session.terminal.killProcess('') 156 | ``` 157 | 158 | ### Start a process 159 | 160 | You can **start a new process** in the session by calling `session.process.start`. The only required option is the `cmd`, but you can also define the `rootdir` and `envVars` options that the command should be executed with. 161 | 162 | > If you want to connect to the same process when you reconnect to a session you can use the `processID` option when starting the process. This is currently primarily used for debugging purposes. 163 | 164 | You **send the stdin to the process** by calling `proc.sendStdin`. 165 | 166 | You can **manually kill** the process by calling `proc.kill`. 167 | 168 | ```ts 169 | const proc = await session.process.start({ 170 | cmd: 'echo 2', 171 | onStdout: stdout => consoel.log(stdout), 172 | onStderr: stderr => console.log(stderr), 173 | onExit: () => console.log('exit'), 174 | envVars: { ['ENV']: 'prod' }, 175 | rootdir: '/', 176 | processID: '', 177 | }) 178 | 179 | await proc.kill() 180 | 181 | await proc.sendStdin('\n') 182 | 183 | console.log(proc.processID) 184 | ``` 185 | 186 | ## Development 187 | 188 | You generate the types for Devbook API from OpenAPI spec by calling: 189 | 190 | ```sh 191 | npm run generate 192 | ``` 193 | 194 | You build the SDK by calling: 195 | 196 | ```sh 197 | npm run build 198 | ``` 199 | 200 | You release a new version of the NPM package by tagging commit with a tag in the `v*.*.*` format and pushing in to GitHub. 201 | 202 | ### Subtrees 203 | 204 | #### shared 205 | 206 | Shared is a subtree made from https://github.com/devbookhq/shared repository. 207 | 208 | The subtree commands you need for controling this repo are: 209 | 210 | ```bash 211 | git subtree add --prefix shared https://github.com/devbookhq/shared.git master 212 | ``` 213 | 214 | ```bash 215 | git subtree pull --prefix shared https://github.com/devbookhq/shared.git master 216 | ``` 217 | 218 | ```bash 219 | git subtree push --prefix shared https://github.com/devbookhq/shared.git master 220 | ``` 221 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@devbookhq/sdk", 3 | "version": "2.5.10", 4 | "description": "SDK for managing Devbook sessions from JavaScript/TypeScript", 5 | "homepage": "https://usedevbook.com", 6 | "license": "SEE LICENSE IN LICENSE", 7 | "author": { 8 | "name": "FoundryLabs, Inc.", 9 | "email": "hello@usedevbook.com", 10 | "url": "https://usedevbook.com" 11 | }, 12 | "bugs": "https://github.com/DevbookHQ/sdk/issues", 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/DevbookHQ/sdk" 16 | }, 17 | "main": "dist/cjs/index.js", 18 | "module": "dist/esm/index.js", 19 | "umd": "dist/umd/index.js", 20 | "scripts": { 21 | "prepublishOnly": "rollup -c", 22 | "build": "rollup -c", 23 | "watch": "rollup -c -w", 24 | "measure": "node test/performance.mjs", 25 | "run": "node test/run.mjs", 26 | "fix": "npx eslint **/src/**/*.{ts,js} --fix", 27 | "format": "prettier --check --ignore-path .gitignore .", 28 | "format:fix": "prettier --write --ignore-path .gitignore .", 29 | "generate": "openapi-typescript shared/api/openapi.yml -x api_key --immutable-types --output src/api/schema.gen.ts", 30 | "prepare": "husky install" 31 | }, 32 | "devDependencies": { 33 | "@rollup/plugin-node-resolve": "^14.1.0", 34 | "@trivago/prettier-plugin-sort-imports": "^3.3.0", 35 | "@types/node": "^18.7.23", 36 | "@types/normalize-path": "^3.0.0", 37 | "@typescript-eslint/eslint-plugin": "^5.38.1", 38 | "@typescript-eslint/parser": "^5.38.1", 39 | "eslint": "^8.24.0", 40 | "eslint-config-prettier": "^8.5.0", 41 | "eslint-plugin-prettier": "^4.2.1", 42 | "eslint-plugin-unused-imports": "^2.0.0", 43 | "husky": "^8.0.1", 44 | "lint-staged": "^13.0.3", 45 | "openapi-typescript": "^5.4.1", 46 | "prettier": "^2.7.1", 47 | "rollup": "^2.79.1", 48 | "rollup-plugin-auto-external": "^2.0.0", 49 | "rollup-plugin-polyfill-node": "^0.10.2", 50 | "rollup-plugin-terser": "^7.0.2", 51 | "rollup-plugin-typescript2": "^0.34.0", 52 | "typescript": "^4.8.3" 53 | }, 54 | "files": [ 55 | "dist", 56 | "src", 57 | "README.md", 58 | "LICENSE", 59 | "package.json", 60 | "package-lock.json" 61 | ], 62 | "keywords": [ 63 | "devbook", 64 | "documentation", 65 | "sandbox", 66 | "code", 67 | "runtime", 68 | "vm", 69 | "nodejs", 70 | "javascript", 71 | "typescript" 72 | ], 73 | "dependencies": { 74 | "cross-fetch": "^3.1.5", 75 | "normalize-path": "^3.0.0", 76 | "openapi-typescript-fetch": "^1.1.3", 77 | "rpc-websocket-client": "^1.1.4" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | tabWidth: 2, 4 | semi: false, 5 | arrowParens: 'avoid', 6 | trailingComma: 'all', 7 | singleAttributePerLine: true, 8 | importOrder: ['', '^[./]'], 9 | importOrderSeparation: true, 10 | importOrderSortSpecifiers: true, 11 | printWidth: 90, 12 | } 13 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import nodeResolve from '@rollup/plugin-node-resolve' 2 | import autoExternal from 'rollup-plugin-auto-external' 3 | import nodePolyfills from 'rollup-plugin-polyfill-node' 4 | import { terser } from 'rollup-plugin-terser' 5 | import typescript from 'rollup-plugin-typescript2' 6 | 7 | import pkg from './package.json' 8 | 9 | export default { 10 | input: 'src/index.ts', 11 | output: [ 12 | { 13 | name: pkg.name, 14 | file: pkg.umd, 15 | format: 'umd', 16 | sourcemap: true, 17 | }, 18 | { 19 | name: pkg.name, 20 | file: pkg.main, 21 | format: 'cjs', 22 | sourcemap: true, 23 | exports: 'auto', 24 | }, 25 | { 26 | name: pkg.name, 27 | file: pkg.module, 28 | format: 'es', 29 | sourcemap: true, 30 | }, 31 | ], 32 | external: ['cross-fetch', 'cross-fetch/polyfill'], 33 | plugins: [ 34 | autoExternal({ builtins: false }), 35 | typescript(), 36 | nodePolyfills(), 37 | nodeResolve({ 38 | preferBuiltins: true, 39 | browser: true, 40 | }), 41 | terser(), 42 | ], 43 | } 44 | -------------------------------------------------------------------------------- /shared/README.md: -------------------------------------------------------------------------------- 1 | # Shared 2 | 3 | Shared types and configuration for Devbook. 4 | -------------------------------------------------------------------------------- /shared/api/openapi.yml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | version: 1.0.0 4 | title: Devbook 5 | description: Devbook API 6 | 7 | servers: 8 | - url: https://ondevbook.com/ 9 | description: API endpoint 10 | - url: https://{sessionID}-{clientID}.ondevbook.com/ 11 | description: Session endpoint without specified port 12 | variables: 13 | sessionID: 14 | description: ID of the session 15 | default: _sessionID 16 | clientID: 17 | description: ID of the client 18 | default: _clientID 19 | - url: https://{port}-{sessionID}-{clientID}.ondevbook.com/ 20 | description: Session endpoint with specificed port 21 | variables: 22 | sessionID: 23 | description: ID of the session 24 | default: _sessionID 25 | clientID: 26 | description: ID of the client 27 | default: _clientID 28 | port: 29 | description: Port to connect to 30 | default: '8080' 31 | 32 | components: 33 | securitySchemes: 34 | ApiKeyAuth: 35 | name: api_key 36 | type: apiKey 37 | in: query 38 | 39 | parameters: 40 | apiKeyOpt: 41 | name: api_key 42 | in: query 43 | required: false 44 | schema: 45 | type: string 46 | apiKeyReq: 47 | name: api_key 48 | in: query 49 | required: false 50 | schema: 51 | type: string 52 | codeSnippetID: 53 | name: codeSnippetID 54 | in: path 55 | required: true 56 | schema: 57 | type: string 58 | sessionID: 59 | name: sessionID 60 | in: path 61 | required: true 62 | schema: 63 | type: string 64 | 65 | responses: 66 | 400: 67 | description: Bad request 68 | content: 69 | application/json: 70 | schema: 71 | $ref: '#/components/schemas/Error' 72 | 401: 73 | description: Authentication error 74 | content: 75 | application/json: 76 | schema: 77 | $ref: '#/components/schemas/Error' 78 | 500: 79 | description: Server error 80 | content: 81 | application/json: 82 | schema: 83 | $ref: '#/components/schemas/Error' 84 | 85 | schemas: 86 | HubDatabase: 87 | required: 88 | - dbURL 89 | properties: 90 | dbURL: 91 | type: string 92 | description: Connectiong string to the database. 93 | 94 | Template: 95 | type: string 96 | enum: 97 | - Nodejs 98 | - Go 99 | - Bash 100 | - Rust 101 | - Python3 102 | - Typescript 103 | EnvironmentState: 104 | type: string 105 | enum: 106 | - Building 107 | - Failed 108 | - Done 109 | 110 | NewEnvironment: 111 | required: 112 | - template 113 | - deps 114 | properties: 115 | template: 116 | $ref: '#/components/schemas/Template' 117 | deps: 118 | type: array 119 | items: 120 | type: string 121 | EnvironmentStateUpdate: 122 | required: 123 | - state 124 | properties: 125 | state: 126 | $ref: '#/components/schemas/EnvironmentState' 127 | 128 | NewSession: 129 | required: 130 | - codeSnippetID 131 | properties: 132 | editEnabled: 133 | type: boolean 134 | default: false 135 | description: Option determining if the session is a shared persistent edit session 136 | codeSnippetID: 137 | type: string 138 | description: Identifier of a code snippet which which is the environment associated 139 | Session: 140 | required: 141 | - sessionID 142 | - clientID 143 | - editEnabled 144 | - codeSnippetID 145 | properties: 146 | codeSnippetID: 147 | type: string 148 | description: Identifier of a code snippet which which is the environment associated 149 | editEnabled: 150 | type: boolean 151 | description: Information if the session is a shared persistent edit session 152 | sessionID: 153 | type: string 154 | description: Identifier of the session 155 | clientID: 156 | type: string 157 | description: Identifier of the client 158 | 159 | Error: 160 | required: 161 | - code 162 | - message 163 | properties: 164 | code: 165 | type: integer 166 | format: int32 167 | description: Error code 168 | message: 169 | type: string 170 | description: Error 171 | 172 | tags: 173 | - name: sessions 174 | description: Managing VM sessions 175 | - name: envs 176 | description: Environment for VM 177 | 178 | paths: 179 | /health: 180 | get: 181 | description: Health check 182 | responses: 183 | 200: 184 | description: Request was successful 185 | 401: 186 | $ref: '#/components/responses/401' 187 | 188 | /sessions: 189 | get: 190 | description: List all sessions 191 | tags: [sessions] 192 | parameters: 193 | - $ref: '#/components/parameters/apiKeyReq' 194 | responses: 195 | 200: 196 | description: Successfully returned all sessions 197 | content: 198 | application/json: 199 | schema: 200 | type: array 201 | items: 202 | allOf: 203 | - $ref: '#/components/schemas/Session' 204 | 401: 205 | $ref: '#/components/responses/401' 206 | 500: 207 | $ref: '#/components/responses/500' 208 | post: 209 | description: Create a session on the server 210 | tags: [sessions] 211 | parameters: 212 | - $ref: '#/components/parameters/apiKeyOpt' 213 | requestBody: 214 | required: true 215 | content: 216 | application/json: 217 | schema: 218 | $ref: '#/components/schemas/NewSession' 219 | responses: 220 | 201: 221 | description: Successfully created a session 222 | content: 223 | application/json: 224 | schema: 225 | $ref: '#/components/schemas/Session' 226 | 401: 227 | $ref: '#/components/responses/401' 228 | 400: 229 | $ref: '#/components/responses/400' 230 | 500: 231 | $ref: '#/components/responses/500' 232 | 233 | /sessions/{sessionID}: 234 | delete: 235 | description: Delete a session on the server 236 | tags: [sessions] 237 | parameters: 238 | - $ref: '#/components/parameters/apiKeyReq' 239 | - $ref: '#/components/parameters/sessionID' 240 | responses: 241 | 204: 242 | description: Successfully deleted the session 243 | 401: 244 | $ref: '#/components/responses/401' 245 | 500: 246 | $ref: '#/components/responses/500' 247 | 248 | /sessions/{sessionID}/refresh: 249 | post: 250 | description: Refresh the session extending its time to live 251 | tags: [sessions] 252 | parameters: 253 | - $ref: '#/components/parameters/apiKeyOpt' 254 | - $ref: '#/components/parameters/sessionID' 255 | responses: 256 | 204: 257 | description: Successfully refreshed the session 258 | 401: 259 | $ref: '#/components/responses/401' 260 | 404: 261 | description: Error refreshing session - session not found 262 | content: 263 | application/json: 264 | schema: 265 | $ref: '#/components/schemas/Error' 266 | 267 | /envs/{codeSnippetID}: 268 | post: 269 | description: Create a new env for a code snippet 270 | tags: [envs] 271 | parameters: 272 | - $ref: '#/components/parameters/apiKeyReq' 273 | - $ref: '#/components/parameters/codeSnippetID' 274 | requestBody: 275 | required: true 276 | content: 277 | application/json: 278 | schema: 279 | $ref: '#/components/schemas/NewEnvironment' 280 | responses: 281 | 204: 282 | description: Successfully created an environment 283 | 400: 284 | $ref: '#/components/responses/400' 285 | 401: 286 | $ref: '#/components/responses/401' 287 | 500: 288 | $ref: '#/components/responses/500' 289 | delete: 290 | description: Delete the code snippet environment 291 | tags: [envs] 292 | parameters: 293 | - $ref: '#/components/parameters/apiKeyReq' 294 | - $ref: '#/components/parameters/codeSnippetID' 295 | responses: 296 | 204: 297 | description: Successfully deleted the environment 298 | 400: 299 | description: Cannot delete the environment 300 | content: 301 | application/json: 302 | schema: 303 | $ref: '#/components/schemas/Error' 304 | 401: 305 | $ref: '#/components/responses/401' 306 | 500: 307 | $ref: '#/components/responses/500' 308 | patch: 309 | description: Update the environment of the code snippet to match the edit environment 310 | tags: [envs] 311 | parameters: 312 | - $ref: '#/components/parameters/apiKeyReq' 313 | - $ref: '#/components/parameters/codeSnippetID' 314 | responses: 315 | 204: 316 | description: Updated the edit environment for code snippet 317 | 400: 318 | $ref: '#/components/responses/400' 319 | 401: 320 | $ref: '#/components/responses/401' 321 | 500: 322 | $ref: '#/components/responses/500' 323 | 324 | /envs/{codeSnippetID}/state: 325 | put: 326 | description: Update the state of the environment 327 | tags: [envs] 328 | parameters: 329 | - $ref: '#/components/parameters/apiKeyReq' 330 | - $ref: '#/components/parameters/codeSnippetID' 331 | requestBody: 332 | required: true 333 | content: 334 | application/json: 335 | schema: 336 | $ref: '#/components/schemas/EnvironmentStateUpdate' 337 | responses: 338 | 204: 339 | description: Publishing the edit environment for code snippet 340 | 400: 341 | $ref: '#/components/responses/400' 342 | 401: 343 | $ref: '#/components/responses/401' 344 | 345 | /prisma-hub/db: 346 | post: 347 | description: Creates a new hub database 348 | responses: 349 | 201: 350 | description: Successfully created a new hub database 351 | content: 352 | application/json: 353 | schema: 354 | $ref: '#/components/schemas/HubDatabase' 355 | 500: 356 | $ref: '#/components/responses/500' 357 | -------------------------------------------------------------------------------- /src/api/cross-fetch-polyfill.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'cross-fetch/polyfill' 2 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | import 'cross-fetch/polyfill' 2 | import { Fetcher } from 'openapi-typescript-fetch' 3 | 4 | import { SESSION_DOMAIN } from '../constants' 5 | import type { components, paths } from './schema.gen' 6 | 7 | const client = Fetcher.for() 8 | 9 | client.configure({ 10 | baseUrl: `https://${SESSION_DOMAIN}`, 11 | }) 12 | 13 | export default client 14 | export type { components, paths } 15 | -------------------------------------------------------------------------------- /src/api/schema.gen.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file was auto-generated by openapi-typescript. 3 | * Do not make direct changes to the file. 4 | */ 5 | 6 | export interface paths { 7 | readonly '/health': { 8 | /** Health check */ 9 | readonly get: { 10 | readonly responses: { 11 | /** Request was successful */ 12 | readonly 200: unknown 13 | readonly 401: components['responses']['401'] 14 | } 15 | } 16 | } 17 | readonly '/sessions': { 18 | /** List all sessions */ 19 | readonly get: { 20 | readonly parameters: { 21 | readonly query: { 22 | readonly api_key?: components['parameters']['apiKeyReq'] 23 | } 24 | } 25 | readonly responses: { 26 | /** Successfully returned all sessions */ 27 | readonly 200: { 28 | readonly content: { 29 | readonly 'application/json': readonly components['schemas']['Session'][] 30 | } 31 | } 32 | readonly 401: components['responses']['401'] 33 | readonly 500: components['responses']['500'] 34 | } 35 | } 36 | /** Create a session on the server */ 37 | readonly post: { 38 | readonly parameters: { 39 | readonly query: { 40 | readonly api_key?: components['parameters']['apiKeyOpt'] 41 | } 42 | } 43 | readonly responses: { 44 | /** Successfully created a session */ 45 | readonly 201: { 46 | readonly content: { 47 | readonly 'application/json': components['schemas']['Session'] 48 | } 49 | } 50 | readonly 400: components['responses']['400'] 51 | readonly 401: components['responses']['401'] 52 | readonly 500: components['responses']['500'] 53 | } 54 | readonly requestBody: { 55 | readonly content: { 56 | readonly 'application/json': components['schemas']['NewSession'] 57 | } 58 | } 59 | } 60 | } 61 | readonly '/sessions/{sessionID}': { 62 | /** Delete a session on the server */ 63 | readonly delete: { 64 | readonly parameters: { 65 | readonly query: { 66 | readonly api_key?: components['parameters']['apiKeyReq'] 67 | } 68 | readonly path: { 69 | readonly sessionID: components['parameters']['sessionID'] 70 | } 71 | } 72 | readonly responses: { 73 | /** Successfully deleted the session */ 74 | readonly 204: never 75 | readonly 401: components['responses']['401'] 76 | readonly 500: components['responses']['500'] 77 | } 78 | } 79 | } 80 | readonly '/sessions/{sessionID}/refresh': { 81 | /** Refresh the session extending its time to live */ 82 | readonly post: { 83 | readonly parameters: { 84 | readonly query: { 85 | readonly api_key?: components['parameters']['apiKeyOpt'] 86 | } 87 | readonly path: { 88 | readonly sessionID: components['parameters']['sessionID'] 89 | } 90 | } 91 | readonly responses: { 92 | /** Successfully refreshed the session */ 93 | readonly 204: never 94 | readonly 401: components['responses']['401'] 95 | /** Error refreshing session - session not found */ 96 | readonly 404: { 97 | readonly content: { 98 | readonly 'application/json': components['schemas']['Error'] 99 | } 100 | } 101 | } 102 | } 103 | } 104 | readonly '/envs/{codeSnippetID}': { 105 | /** Create a new env for a code snippet */ 106 | readonly post: { 107 | readonly parameters: { 108 | readonly query: { 109 | readonly api_key?: components['parameters']['apiKeyReq'] 110 | } 111 | readonly path: { 112 | readonly codeSnippetID: components['parameters']['codeSnippetID'] 113 | } 114 | } 115 | readonly responses: { 116 | /** Successfully created an environment */ 117 | readonly 204: never 118 | readonly 400: components['responses']['400'] 119 | readonly 401: components['responses']['401'] 120 | readonly 500: components['responses']['500'] 121 | } 122 | readonly requestBody: { 123 | readonly content: { 124 | readonly 'application/json': components['schemas']['NewEnvironment'] 125 | } 126 | } 127 | } 128 | /** Delete the code snippet environment */ 129 | readonly delete: { 130 | readonly parameters: { 131 | readonly query: { 132 | readonly api_key?: components['parameters']['apiKeyReq'] 133 | } 134 | readonly path: { 135 | readonly codeSnippetID: components['parameters']['codeSnippetID'] 136 | } 137 | } 138 | readonly responses: { 139 | /** Successfully deleted the environment */ 140 | readonly 204: never 141 | /** Cannot delete the environment */ 142 | readonly 400: { 143 | readonly content: { 144 | readonly 'application/json': components['schemas']['Error'] 145 | } 146 | } 147 | readonly 401: components['responses']['401'] 148 | readonly 500: components['responses']['500'] 149 | } 150 | } 151 | /** Update the environment of the code snippet to match the edit environment */ 152 | readonly patch: { 153 | readonly parameters: { 154 | readonly query: { 155 | readonly api_key?: components['parameters']['apiKeyReq'] 156 | } 157 | readonly path: { 158 | readonly codeSnippetID: components['parameters']['codeSnippetID'] 159 | } 160 | } 161 | readonly responses: { 162 | /** Updated the edit environment for code snippet */ 163 | readonly 204: never 164 | readonly 400: components['responses']['400'] 165 | readonly 401: components['responses']['401'] 166 | readonly 500: components['responses']['500'] 167 | } 168 | } 169 | } 170 | readonly '/envs/{codeSnippetID}/state': { 171 | /** Update the state of the environment */ 172 | readonly put: { 173 | readonly parameters: { 174 | readonly query: { 175 | readonly api_key?: components['parameters']['apiKeyReq'] 176 | } 177 | readonly path: { 178 | readonly codeSnippetID: components['parameters']['codeSnippetID'] 179 | } 180 | } 181 | readonly responses: { 182 | /** Publishing the edit environment for code snippet */ 183 | readonly 204: never 184 | readonly 400: components['responses']['400'] 185 | readonly 401: components['responses']['401'] 186 | } 187 | readonly requestBody: { 188 | readonly content: { 189 | readonly 'application/json': components['schemas']['EnvironmentStateUpdate'] 190 | } 191 | } 192 | } 193 | } 194 | readonly '/prisma-hub/db': { 195 | /** Creates a new hub database */ 196 | readonly post: { 197 | readonly responses: { 198 | /** Successfully created a new hub database */ 199 | readonly 201: { 200 | readonly content: { 201 | readonly 'application/json': components['schemas']['HubDatabase'] 202 | } 203 | } 204 | readonly 500: components['responses']['500'] 205 | } 206 | } 207 | } 208 | } 209 | 210 | export interface components { 211 | readonly schemas: { 212 | readonly HubDatabase: { 213 | /** @description Connectiong string to the database. */ 214 | readonly dbURL: string 215 | } 216 | /** @enum {string} */ 217 | readonly Template: 'Nodejs' | 'Go' | 'Bash' | 'Rust' | 'Python3' | 'Typescript' 218 | /** @enum {string} */ 219 | readonly EnvironmentState: 'Building' | 'Failed' | 'Done' 220 | readonly NewEnvironment: { 221 | readonly template: components['schemas']['Template'] 222 | readonly deps: readonly string[] 223 | } 224 | readonly EnvironmentStateUpdate: { 225 | readonly state: components['schemas']['EnvironmentState'] 226 | } 227 | readonly NewSession: { 228 | /** 229 | * @description Option determining if the session is a shared persistent edit session 230 | * @default false 231 | */ 232 | readonly editEnabled?: boolean 233 | /** @description Identifier of a code snippet which which is the environment associated */ 234 | readonly codeSnippetID: string 235 | } 236 | readonly Session: { 237 | /** @description Identifier of a code snippet which which is the environment associated */ 238 | readonly codeSnippetID: string 239 | /** @description Information if the session is a shared persistent edit session */ 240 | readonly editEnabled: boolean 241 | /** @description Identifier of the session */ 242 | readonly sessionID: string 243 | /** @description Identifier of the client */ 244 | readonly clientID: string 245 | } 246 | readonly Error: { 247 | /** 248 | * Format: int32 249 | * @description Error code 250 | */ 251 | readonly code: number 252 | /** @description Error */ 253 | readonly message: string 254 | } 255 | } 256 | readonly responses: { 257 | /** Bad request */ 258 | readonly 400: { 259 | readonly content: { 260 | readonly 'application/json': components['schemas']['Error'] 261 | } 262 | } 263 | /** Authentication error */ 264 | readonly 401: { 265 | readonly content: { 266 | readonly 'application/json': components['schemas']['Error'] 267 | } 268 | } 269 | /** Server error */ 270 | readonly 500: { 271 | readonly content: { 272 | readonly 'application/json': components['schemas']['Error'] 273 | } 274 | } 275 | } 276 | readonly parameters: { 277 | readonly apiKeyOpt: string 278 | readonly apiKeyReq: string 279 | readonly codeSnippetID: string 280 | readonly sessionID: string 281 | } 282 | } 283 | 284 | export interface operations {} 285 | 286 | export interface external {} 287 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const SESSION_REFRESH_PERIOD = 5_000 // 5s 2 | export const WS_RECONNECT_INTERVAL = 100 // 100ms 3 | 4 | export const SESSION_DOMAIN = 'ondevbook.com' 5 | export const WS_PORT = 49982 6 | export const WS_ROUTE = '/ws' 7 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Session } from './session' 2 | export type { SessionOpts } from './session' 3 | export { CodeSnippetExecState } from './session/codeSnippet' 4 | export type { 5 | CodeSnippetManager, 6 | CodeSnippetStateHandler, 7 | CodeSnippetStderrHandler, 8 | CodeSnippetStdoutHandler, 9 | CodeSnippetSubscriptionHandler, 10 | CodeSnippetSubscriptionHandlerType, 11 | OpenedPort, 12 | } from './session/codeSnippet' 13 | export type { OutResponse, OutStderrResponse, OutStdoutResponse } from './session/out' 14 | export { OutType } from './session/out' 15 | export type { TerminalManager, TerminalSession, ChildProcess } from './session/terminal' 16 | export type { FilesystemManager, FileInfo } from './session/filesystem' 17 | export { 18 | default as FilesystemWatcher, 19 | FilesystemOperation, 20 | } from './session/filesystemWatcher' 21 | export type { 22 | FilesystemEvent, 23 | FilesystemEventListener, 24 | } from './session/filesystemWatcher' 25 | 26 | export type { Process, ProcessManager } from './session/process' 27 | export type { EnvVars } from './session/envVars' 28 | export { default as api } from './api' 29 | export type { components, paths } from './api' 30 | -------------------------------------------------------------------------------- /src/session/codeSnippet.ts: -------------------------------------------------------------------------------- 1 | import { EnvVars } from './envVars' 2 | import { OutStderrResponse, OutStdoutResponse } from './out' 3 | 4 | export const codeSnippetService = 'codeSnippet' 5 | 6 | export enum CodeSnippetExecState { 7 | Running = 'Running', 8 | Stopped = 'Stopped', 9 | } 10 | 11 | export interface OpenedPort { 12 | State: string 13 | Ip: string 14 | Port: number 15 | } 16 | 17 | export type CodeSnippetStateHandler = (state: CodeSnippetExecState) => void 18 | export type CodeSnippetStderrHandler = (o: OutStderrResponse) => void 19 | export type CodeSnippetStdoutHandler = (o: OutStdoutResponse) => void 20 | export type ScanOpenedPortsHandler = (ports: OpenedPort[]) => void 21 | 22 | export type CodeSnippetSubscriptionHandler = 23 | | CodeSnippetStateHandler 24 | | CodeSnippetStderrHandler 25 | | CodeSnippetStdoutHandler 26 | | ScanOpenedPortsHandler 27 | 28 | export type CodeSnippetSubscriptionHandlerType = { 29 | state: CodeSnippetStateHandler 30 | stderr: CodeSnippetStderrHandler 31 | stdout: CodeSnippetStdoutHandler 32 | scanOpenedPorts: ScanOpenedPortsHandler 33 | } 34 | 35 | export interface CodeSnippetManager { 36 | readonly run: (code: string, envVars?: EnvVars) => Promise 37 | readonly stop: () => Promise 38 | } 39 | -------------------------------------------------------------------------------- /src/session/envVars.ts: -------------------------------------------------------------------------------- 1 | export type EnvVars = { 2 | [key: string]: string 3 | } 4 | -------------------------------------------------------------------------------- /src/session/filesystem.ts: -------------------------------------------------------------------------------- 1 | import FilesystemWatcher from './filesystemWatcher' 2 | 3 | export const filesystemService = 'filesystem' 4 | 5 | export interface FileInfo { 6 | isDir: boolean 7 | name: string 8 | } 9 | 10 | export interface FilesystemManager { 11 | readonly write: (path: string, content: string) => Promise 12 | readonly read: (path: string) => Promise 13 | readonly remove: (path: string) => Promise 14 | readonly list: (path: string) => Promise 15 | readonly makeDir: (path: string) => Promise 16 | readonly watchDir: (path: string) => FilesystemWatcher 17 | } 18 | -------------------------------------------------------------------------------- /src/session/filesystemWatcher.ts: -------------------------------------------------------------------------------- 1 | import { filesystemService } from './filesystem' 2 | import SessionConnection from './sessionConnection' 3 | 4 | export enum FilesystemOperation { 5 | Create = 'Create', 6 | Write = 'Write', 7 | Remove = 'Remove', 8 | Rename = 'Rename', 9 | Chmod = 'Chmod', 10 | } 11 | 12 | export interface FilesystemEvent { 13 | path: string 14 | name: string 15 | operation: FilesystemOperation 16 | // Unix epoch in nanoseconds 17 | timestamp: number 18 | isDir: boolean 19 | } 20 | 21 | export type FilesystemEventListener = (event: FilesystemEvent) => void 22 | 23 | class FilesystemWatcher { 24 | // Listeners to filesystem events. 25 | // Users of the this class can add their listeners to filesystem events 26 | // via `this.addEventListeners` 27 | private listeners: Set 28 | private rpcSubscriptionID?: string 29 | 30 | constructor(private sessConn: SessionConnection, private path: string) { 31 | this.listeners = new Set() 32 | } 33 | 34 | // Starts watching the path that was passed to the contructor 35 | async start() { 36 | // Already started. 37 | if (this.rpcSubscriptionID) return 38 | 39 | this.handleFilesystemEvents = this.handleFilesystemEvents.bind(this) 40 | 41 | this.rpcSubscriptionID = await this.sessConn.subscribe( 42 | filesystemService, 43 | this.handleFilesystemEvents, 44 | 'watchDir', 45 | this.path, 46 | ) 47 | } 48 | 49 | // Stops watching the path and removes all listeners. 50 | async stop() { 51 | this.listeners.clear() 52 | if (this.rpcSubscriptionID) { 53 | await this.sessConn.unsubscribe(this.rpcSubscriptionID) 54 | } 55 | } 56 | 57 | addEventListener(l: FilesystemEventListener) { 58 | this.listeners.add(l) 59 | return () => this.listeners.delete(l) 60 | } 61 | 62 | private handleFilesystemEvents(fsChange: FilesystemEvent) { 63 | this.listeners.forEach(l => { 64 | l(fsChange) 65 | }) 66 | } 67 | } 68 | 69 | export default FilesystemWatcher 70 | -------------------------------------------------------------------------------- /src/session/index.ts: -------------------------------------------------------------------------------- 1 | import normalizePath from 'normalize-path' 2 | 3 | import { id } from '../utils/id' 4 | import { createDeferredPromise, formatSettledErrors } from '../utils/promise' 5 | import { 6 | CodeSnippetExecState, 7 | CodeSnippetManager, 8 | CodeSnippetStateHandler, 9 | CodeSnippetStderrHandler, 10 | CodeSnippetStdoutHandler, 11 | ScanOpenedPortsHandler, 12 | codeSnippetService, 13 | } from './codeSnippet' 14 | import { FileInfo, FilesystemManager, filesystemService } from './filesystem' 15 | import FilesystemWatcher from './filesystemWatcher' 16 | import { ProcessManager, processService } from './process' 17 | import SessionConnection, { SessionConnectionOpts } from './sessionConnection' 18 | import { TerminalManager, terminalService } from './terminal' 19 | 20 | export interface CodeSnippetOpts { 21 | onStateChange?: CodeSnippetStateHandler 22 | onStderr?: CodeSnippetStderrHandler 23 | onStdout?: CodeSnippetStdoutHandler 24 | onScanPorts?: ScanOpenedPortsHandler 25 | } 26 | 27 | export interface SessionOpts extends SessionConnectionOpts { 28 | codeSnippet?: CodeSnippetOpts 29 | } 30 | 31 | class Session extends SessionConnection { 32 | codeSnippet?: CodeSnippetManager 33 | terminal?: TerminalManager 34 | filesystem?: FilesystemManager 35 | process?: ProcessManager 36 | 37 | private readonly codeSnippetOpts?: CodeSnippetOpts 38 | 39 | constructor(opts: SessionOpts) { 40 | super(opts) 41 | this.codeSnippetOpts = opts.codeSnippet 42 | } 43 | 44 | async open() { 45 | await super.open() 46 | 47 | await this.handleSubscriptions( 48 | this.codeSnippetOpts?.onStateChange 49 | ? this.subscribe(codeSnippetService, this.codeSnippetOpts.onStateChange, 'state') 50 | : undefined, 51 | this.codeSnippetOpts?.onStderr 52 | ? this.subscribe(codeSnippetService, this.codeSnippetOpts.onStderr, 'stderr') 53 | : undefined, 54 | this.codeSnippetOpts?.onStdout 55 | ? this.subscribe(codeSnippetService, this.codeSnippetOpts.onStdout, 'stdout') 56 | : undefined, 57 | this.codeSnippetOpts?.onScanPorts 58 | ? this.subscribe( 59 | codeSnippetService, 60 | this.codeSnippetOpts.onScanPorts, 61 | 'scanOpenedPorts', 62 | ) 63 | : undefined, 64 | ) 65 | 66 | // Init CodeSnippet handler 67 | this.codeSnippet = { 68 | run: async (code, envVars = {}) => { 69 | const state = (await this.call(codeSnippetService, 'run', [ 70 | code, 71 | envVars, 72 | ])) as CodeSnippetExecState 73 | this.codeSnippetOpts?.onStateChange?.(state) 74 | return state 75 | }, 76 | stop: async () => { 77 | const state = (await this.call( 78 | codeSnippetService, 79 | 'stop', 80 | )) as CodeSnippetExecState 81 | this.codeSnippetOpts?.onStateChange?.(state) 82 | return state 83 | }, 84 | } 85 | 86 | // Init Filesystem handler 87 | this.filesystem = { 88 | /** 89 | * List files in a directory. 90 | * @param path path to a directory 91 | * @returns Array of files in a directory 92 | */ 93 | list: async path => { 94 | return (await this.call(filesystemService, 'list', [path])) as FileInfo[] 95 | }, 96 | /** 97 | * Reads the whole content of a file. 98 | * @param path path to a file 99 | * @returns Content of a file 100 | */ 101 | read: async path => { 102 | return (await this.call(filesystemService, 'read', [path])) as string 103 | }, 104 | /** 105 | * Removes a file or a directory. 106 | * @param path path to a file or a directory 107 | */ 108 | remove: async path => { 109 | await this.call(filesystemService, 'remove', [path]) 110 | }, 111 | /** 112 | * Writes content to a new file on path. 113 | * @param path path to a new file. For example '/dirA/dirB/newFile.txt' when creating 'newFile.txt' 114 | * @param content content to write to a new file 115 | */ 116 | write: async (path, content) => { 117 | await this.call(filesystemService, 'write', [path, content]) 118 | }, 119 | /** 120 | * Creates a new directory and all directories along the way if needed on the specified pth. 121 | * @param path path to a new directory. For example '/dirA/dirB' when creating 'dirB'. 122 | */ 123 | makeDir: async path => { 124 | await this.call(filesystemService, 'makeDir', [path]) 125 | }, 126 | /** 127 | * Watches directory for filesystem events. 128 | * @param path path to a directory that will be watched 129 | * @returns new watcher 130 | */ 131 | watchDir: (path: string) => { 132 | const npath = normalizePath(path) 133 | return new FilesystemWatcher(this, npath) 134 | }, 135 | } 136 | 137 | // Init Terminal handler 138 | this.terminal = { 139 | createSession: async ({ 140 | onData, 141 | onChildProcessesChange, 142 | size, 143 | onExit, 144 | terminalID = id(12), 145 | }) => { 146 | const { promise: terminalExited, resolve: triggerExit } = createDeferredPromise() 147 | 148 | const [onDataSubID, onExitSubID, onChildProcessesChangeSubID] = 149 | await this.handleSubscriptions( 150 | this.subscribe(terminalService, onData, 'onData', terminalID), 151 | this.subscribe(terminalService, triggerExit, 'onExit', terminalID), 152 | onChildProcessesChange 153 | ? this.subscribe( 154 | terminalService, 155 | onChildProcessesChange, 156 | 'onChildProcessesChange', 157 | terminalID, 158 | ) 159 | : undefined, 160 | ) 161 | 162 | const { promise: unsubscribing, resolve: handleFinishUnsubscribing } = 163 | createDeferredPromise() 164 | 165 | terminalExited.then(async () => { 166 | const results = await Promise.allSettled([ 167 | this.unsubscribe(onExitSubID), 168 | this.unsubscribe(onDataSubID), 169 | onChildProcessesChangeSubID 170 | ? this.unsubscribe(onChildProcessesChangeSubID) 171 | : undefined, 172 | ]) 173 | 174 | const errMsg = formatSettledErrors(results) 175 | if (errMsg) { 176 | this.logger.error(errMsg) 177 | } 178 | 179 | onExit?.() 180 | handleFinishUnsubscribing() 181 | }) 182 | 183 | try { 184 | await this.call(terminalService, 'start', [terminalID, size.cols, size.rows]) 185 | } catch (err) { 186 | triggerExit() 187 | await unsubscribing 188 | throw err 189 | } 190 | 191 | return { 192 | destroy: async () => { 193 | try { 194 | await this.call(terminalService, 'destroy', [terminalID]) 195 | } finally { 196 | triggerExit() 197 | await unsubscribing 198 | } 199 | }, 200 | resize: async ({ cols, rows }) => { 201 | await this.call(terminalService, 'resize', [terminalID, cols, rows]) 202 | }, 203 | sendData: async data => { 204 | await this.call(terminalService, 'data', [terminalID, data]) 205 | }, 206 | terminalID, 207 | } 208 | }, 209 | killProcess: async pid => { 210 | await this.call(terminalService, 'killProcess', [pid]) 211 | }, 212 | } 213 | 214 | // Init Process handler 215 | this.process = { 216 | start: async ({ 217 | cmd, 218 | onStdout, 219 | onStderr, 220 | onExit, 221 | envVars = {}, 222 | rootdir = '/', 223 | processID = id(12), 224 | }) => { 225 | const { promise: processExited, resolve: triggerExit } = createDeferredPromise() 226 | 227 | const [onExitSubID, onStdoutSubID, onStderrSubID] = 228 | await this.handleSubscriptions( 229 | this.subscribe(processService, triggerExit, 'onExit', processID), 230 | onStdout 231 | ? this.subscribe(processService, onStdout, 'onStdout', processID) 232 | : undefined, 233 | onStderr 234 | ? this.subscribe(processService, onStderr, 'onStderr', processID) 235 | : undefined, 236 | ) 237 | 238 | const { promise: unsubscribing, resolve: handleFinishUnsubscribing } = 239 | createDeferredPromise() 240 | 241 | processExited.then(async () => { 242 | const results = await Promise.allSettled([ 243 | this.unsubscribe(onExitSubID), 244 | onStdoutSubID ? this.unsubscribe(onStdoutSubID) : undefined, 245 | onStderrSubID ? this.unsubscribe(onStderrSubID) : undefined, 246 | ]) 247 | 248 | const errMsg = formatSettledErrors(results) 249 | if (errMsg) { 250 | this.logger.error(errMsg) 251 | } 252 | 253 | onExit?.() 254 | handleFinishUnsubscribing() 255 | }) 256 | 257 | try { 258 | await this.call(processService, 'start', [processID, cmd, envVars, rootdir]) 259 | } catch (err) { 260 | triggerExit() 261 | await unsubscribing 262 | throw err 263 | } 264 | 265 | return { 266 | kill: async () => { 267 | try { 268 | await this.call(processService, 'kill', [processID]) 269 | } finally { 270 | triggerExit() 271 | await unsubscribing 272 | } 273 | }, 274 | processID, 275 | sendStdin: async data => { 276 | await this.call(processService, 'stdin', [processID, data]) 277 | }, 278 | } 279 | }, 280 | } 281 | } 282 | } 283 | 284 | export default Session 285 | -------------------------------------------------------------------------------- /src/session/out.ts: -------------------------------------------------------------------------------- 1 | export enum OutType { 2 | Stdout = 'Stdout', 3 | Stderr = 'Stderr', 4 | } 5 | 6 | export interface OutResponse { 7 | type: OutType 8 | // Unix epoch in nanoseconds 9 | timestamp: number 10 | line: string 11 | } 12 | 13 | export interface OutStdoutResponse extends OutResponse { 14 | type: OutType.Stdout 15 | } 16 | 17 | export interface OutStderrResponse extends OutResponse { 18 | type: OutType.Stderr 19 | } 20 | -------------------------------------------------------------------------------- /src/session/process.ts: -------------------------------------------------------------------------------- 1 | import { EnvVars } from './envVars' 2 | import { OutStderrResponse, OutStdoutResponse } from './out' 3 | 4 | export const processService = 'process' 5 | 6 | export interface Process { 7 | readonly sendStdin: (data: string) => Promise 8 | readonly kill: () => Promise 9 | readonly processID: string 10 | } 11 | 12 | export interface ProcessManager { 13 | readonly start: (opts: { 14 | cmd: string 15 | onStdout?: (o: OutStdoutResponse) => void 16 | onStderr?: (o: OutStderrResponse) => void 17 | onExit?: () => void 18 | envVars?: EnvVars 19 | rootdir?: string 20 | processID?: string 21 | }) => Promise 22 | } 23 | -------------------------------------------------------------------------------- /src/session/sessionConnection.ts: -------------------------------------------------------------------------------- 1 | import { IRpcNotification, RpcWebSocketClient } from 'rpc-websocket-client' 2 | 3 | import api, { components } from '../api' 4 | import { 5 | SESSION_DOMAIN, 6 | SESSION_REFRESH_PERIOD, 7 | WS_PORT, 8 | WS_RECONNECT_INTERVAL, 9 | WS_ROUTE, 10 | } from '../constants' 11 | import Logger from '../utils/logger' 12 | import { assertFulfilled, formatSettledErrors } from '../utils/promise' 13 | import wait from '../utils/wait' 14 | import { codeSnippetService } from './codeSnippet' 15 | import { filesystemService } from './filesystem' 16 | import { processService } from './process' 17 | import { terminalService } from './terminal' 18 | 19 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 20 | type SubscriptionHandler = (result: any) => void 21 | 22 | type Service = 23 | | typeof processService 24 | | typeof codeSnippetService 25 | | typeof filesystemService 26 | | typeof terminalService 27 | 28 | interface Subscriber { 29 | service: Service 30 | subID: string 31 | handler: SubscriptionHandler 32 | } 33 | 34 | export type CloseHandler = () => void 35 | export type DisconnectHandler = () => void 36 | export type ReconnectHandler = () => void 37 | 38 | export interface SessionConnectionOpts { 39 | id: string 40 | apiKey?: string 41 | onClose?: CloseHandler 42 | onDisconnect?: DisconnectHandler 43 | onReconnect?: ReconnectHandler 44 | debug?: boolean 45 | editEnabled?: boolean 46 | __debug_hostname?: string 47 | __debug_port?: number 48 | __debug_devEnv?: 'remote' | 'local' 49 | } 50 | 51 | const createSession = api.path('/sessions').method('post').create({ api_key: true }) 52 | const refreshSession = api 53 | .path('/sessions/{sessionID}/refresh') 54 | .method('post') 55 | .create({ api_key: true }) 56 | 57 | abstract class SessionConnection { 58 | protected readonly logger: Logger 59 | protected session?: components['schemas']['Session'] 60 | protected isOpen = false 61 | 62 | private readonly rpc = new RpcWebSocketClient() 63 | private subscribers: Subscriber[] = [] 64 | 65 | constructor(private readonly opts: SessionConnectionOpts) { 66 | this.logger = new Logger('Session', opts.debug) 67 | this.logger.log(`Session for code snippet "${opts.id}" initialized`) 68 | } 69 | 70 | /** 71 | * Get the hostname for the session or for the specified session's port. 72 | * 73 | * `getHostname` method requires `this` context - you may need to bind it. 74 | * 75 | * @param port specify if you want to connect to a specific port of the session 76 | * @returns hostname of the session or session's port 77 | */ 78 | getHostname(port?: number) { 79 | if (this.opts.__debug_hostname) { 80 | // Debugging remotely (with GitPod) and on local needs different formats of the hostname. 81 | if (port && this.opts.__debug_devEnv === 'remote') { 82 | return `${port}-${this.opts.__debug_hostname}` 83 | } else if (port) { 84 | return `${this.opts.__debug_hostname}:${port}` 85 | } else { 86 | return this.opts.__debug_hostname 87 | } 88 | } 89 | 90 | if (!this.session) { 91 | return undefined 92 | } 93 | 94 | const hostname = `${this.session.sessionID}-${this.session.clientID}.${SESSION_DOMAIN}` 95 | if (port) { 96 | return `${port}-${hostname}` 97 | } else { 98 | return hostname 99 | } 100 | } 101 | 102 | /** 103 | * Close the connection to the session 104 | * 105 | * `close` method requires `this` context - you may need to bind it. 106 | */ 107 | async close() { 108 | if (this.isOpen) { 109 | this.logger.log('Closing', this.session) 110 | this.isOpen = false 111 | 112 | this.logger.log('Unsubscribing...') 113 | const results = await Promise.allSettled( 114 | this.subscribers.map(s => this.unsubscribe(s.subID)), 115 | ) 116 | results.forEach(r => { 117 | if (r.status === 'rejected') { 118 | this.logger.log(`Failed to unsubscribe: "${r.reason}"`) 119 | } 120 | }) 121 | 122 | this.rpc.ws?.close() 123 | this.opts?.onClose?.() 124 | this.logger.log('Disconected from the session') 125 | } 126 | } 127 | 128 | /** 129 | * Open a connection to a new session 130 | * 131 | * `open` method requires `this` context - you may need to bind it. 132 | */ 133 | async open() { 134 | if (this.isOpen || !!this.session) { 135 | throw new Error('Session connect was already called') 136 | } else { 137 | this.isOpen = true 138 | } 139 | 140 | if (!this.opts.__debug_hostname) { 141 | try { 142 | const res = await createSession({ 143 | api_key: this.opts.apiKey, 144 | codeSnippetID: this.opts.id, 145 | editEnabled: this.opts.editEnabled, 146 | }) 147 | this.session = res.data 148 | this.logger.log('Aquired session:', this.session) 149 | 150 | this.refresh(this.session.sessionID) 151 | } catch (e) { 152 | if (e instanceof createSession.Error) { 153 | const error = e.getActualType() 154 | if (error.status === 400) { 155 | throw new Error( 156 | `Error creating session - (${error.status}) bad request: ${error.data.message}`, 157 | ) 158 | } 159 | if (error.status === 401) { 160 | throw new Error( 161 | `Error creating session - (${error.status}) unauthenticated (you need to be authenticated to start an session with persistent edits): ${error.data.message}`, 162 | ) 163 | } 164 | if (error.status === 500) { 165 | throw new Error( 166 | `Error creating session - (${error.status}) server error: ${error.data.message}`, 167 | ) 168 | } 169 | throw e 170 | } 171 | } 172 | } 173 | 174 | const hostname = this.getHostname(this.opts.__debug_port || WS_PORT) 175 | 176 | if (!hostname) { 177 | throw new Error("Cannot get session's hostname") 178 | } 179 | 180 | const protocol = this.opts.__debug_devEnv === 'local' ? 'ws' : 'wss' 181 | const sessionURL = `${protocol}://${hostname}${WS_ROUTE}` 182 | 183 | this.rpc.onError(e => { 184 | this.logger.log('Error in WS session:', this.session, e) 185 | }) 186 | 187 | let isFinished = false 188 | let resolveOpening: (() => void) | undefined 189 | let rejectOpening: (() => void) | undefined 190 | 191 | const openingPromise = new Promise((resolve, reject) => { 192 | resolveOpening = () => { 193 | if (isFinished) return 194 | isFinished = true 195 | resolve() 196 | } 197 | rejectOpening = () => { 198 | if (isFinished) return 199 | isFinished = true 200 | reject() 201 | } 202 | }) 203 | 204 | this.rpc.onOpen(() => { 205 | this.logger.log('Connected to session:', this.session) 206 | resolveOpening?.() 207 | }) 208 | 209 | this.rpc.onClose(async e => { 210 | this.logger.log('Closing WS connection to session:', this.session, e) 211 | if (this.isOpen) { 212 | this.opts.onDisconnect?.() 213 | await wait(WS_RECONNECT_INTERVAL) 214 | this.logger.log('Reconnecting to session:', this.session) 215 | try { 216 | // When the WS connection closes the subscribers in devbookd are removed. 217 | // We want to delete the subscriber handlers here so there are no orphans. 218 | this.subscribers = [] 219 | await this.rpc.connect(sessionURL) 220 | this.opts.onReconnect?.() 221 | this.logger.log('Reconnected to session:', this.session) 222 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 223 | } catch (e: any) { 224 | this.logger.log('Failed reconnecting to session:', this.session, e) 225 | } 226 | } else { 227 | rejectOpening?.() 228 | } 229 | }) 230 | 231 | this.rpc.onNotification.push(this.handleNotification.bind(this)) 232 | 233 | try { 234 | this.logger.log('Connection to session:', this.session) 235 | await this.rpc.connect(sessionURL) 236 | 237 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 238 | } catch (e: any) { 239 | this.logger.log('Error connecting to session', this.session, e) 240 | } 241 | 242 | await openingPromise 243 | } 244 | 245 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 246 | async call(service: Service, method: string, params?: any[]) { 247 | return this.rpc.call(`${service}_${method}`, params) 248 | } 249 | 250 | async handleSubscriptions< 251 | T extends (ReturnType | undefined)[], 252 | >( 253 | ...subs: T 254 | ): Promise<{ 255 | [P in keyof T]: Awaited 256 | }> { 257 | const results = await Promise.allSettled(subs) 258 | 259 | if (results.every(r => r.status === 'fulfilled')) { 260 | return results.map(r => (r.status === 'fulfilled' ? r.value : undefined)) as { 261 | [P in keyof T]: Awaited 262 | } 263 | } 264 | 265 | await Promise.all( 266 | results 267 | .filter(assertFulfilled) 268 | .map(r => (r.value ? this.unsubscribe(r.value) : undefined)), 269 | ) 270 | 271 | throw new Error(formatSettledErrors(results)) 272 | } 273 | 274 | async unsubscribe(subID: string) { 275 | const subscription = this.subscribers.find(s => s.subID === subID) 276 | if (!subscription) return 277 | 278 | await this.call(subscription.service, 'unsubscribe', [subscription.subID]) 279 | 280 | this.subscribers = this.subscribers.filter(s => s !== subscription) 281 | this.logger.log(`Unsubscribed '${subID}' from '${subscription.service}'`) 282 | } 283 | 284 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 285 | async subscribe( 286 | service: Service, 287 | handler: SubscriptionHandler, 288 | method: string, 289 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 290 | ...params: any[] 291 | ) { 292 | const subID = await this.call(service, 'subscribe', [method, ...params]) 293 | 294 | if (typeof subID !== 'string') { 295 | throw new Error( 296 | `Cannot subscribe to ${service}_${method}${ 297 | params.length > 0 ? ' with params [' + params.join(', ') + ']' : '' 298 | }. Expected response should have been a subscription ID, instead we got ${JSON.stringify( 299 | subID, 300 | )}`, 301 | ) 302 | } 303 | 304 | this.subscribers.push({ 305 | handler, 306 | service, 307 | subID, 308 | }) 309 | this.logger.log( 310 | `Subscribed to "${service}_${method}"${ 311 | params.length > 0 ? ' with params [' + params.join(', ') + '] and' : '' 312 | } with id "${subID}"`, 313 | ) 314 | 315 | return subID 316 | } 317 | 318 | private handleNotification(data: IRpcNotification) { 319 | this.subscribers 320 | .filter(s => s.subID === data.params?.subscription) 321 | .forEach(s => s.handler(data.params?.result)) 322 | } 323 | 324 | private async refresh(sessionID: string) { 325 | this.logger.log(`Started refreshing session "${sessionID}"`) 326 | 327 | try { 328 | // eslint-disable-next-line no-constant-condition 329 | while (true) { 330 | if (!this.isOpen) { 331 | this.logger.log('Cannot refresh session - it was closed', this.session) 332 | return 333 | } 334 | 335 | await wait(SESSION_REFRESH_PERIOD) 336 | 337 | try { 338 | this.logger.log(`Refreshed session "${sessionID}"`) 339 | await refreshSession({ 340 | api_key: this.opts.apiKey, 341 | sessionID, 342 | }) 343 | } catch (e) { 344 | if (e instanceof refreshSession.Error) { 345 | const error = e.getActualType() 346 | if (error.status === 404) { 347 | this.logger.error( 348 | `Error refreshing session - (${error.status}): ${error.data.message}`, 349 | ) 350 | return 351 | } 352 | this.logger.error( 353 | `Refreshing session "${sessionID}" failed - (${error.status})`, 354 | ) 355 | } 356 | } 357 | } 358 | } finally { 359 | this.logger.log(`Stopped refreshing session "${sessionID}"`) 360 | this.close() 361 | } 362 | } 363 | } 364 | 365 | export default SessionConnection 366 | -------------------------------------------------------------------------------- /src/session/terminal.ts: -------------------------------------------------------------------------------- 1 | export const terminalService = 'terminal' 2 | 3 | export interface TerminalSession { 4 | readonly sendData: (data: string) => Promise 5 | readonly resize: ({ cols, rows }: { cols: number; rows: number }) => Promise 6 | readonly destroy: () => Promise 7 | readonly terminalID: string 8 | } 9 | 10 | export interface ChildProcess { 11 | cmd: string 12 | pid: number 13 | } 14 | 15 | export interface TerminalManager { 16 | readonly killProcess: (pid: number) => Promise 17 | readonly createSession: (opts: { 18 | onData: (data: string) => void 19 | onExit?: () => void 20 | onChildProcessesChange?: (cps: ChildProcess[]) => void 21 | size: { cols: number; rows: number } 22 | terminalID?: string 23 | }) => Promise 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/id.ts: -------------------------------------------------------------------------------- 1 | export function id(length: number) { 2 | let result = '' 3 | const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' 4 | const charactersLength = characters.length 5 | for (let i = 0; i < length; i++) { 6 | result += characters.charAt(Math.floor(Math.random() * charactersLength)) 7 | } 8 | return result 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | type LogID = string | (() => string) 3 | 4 | class Logger { 5 | constructor(public readonly logID: LogID, public readonly isEnabled = false) {} 6 | 7 | error(...args: any[]) { 8 | console.error(`\x1b[31m[${this.id()} ERROR]\x1b[0m`, ...args) 9 | } 10 | 11 | log(...args: any[]) { 12 | if (this.isEnabled) { 13 | console.log(`\x1b[36m[${this.id()}]\x1b[0m`, ...args) 14 | } 15 | } 16 | 17 | private id() { 18 | if (typeof this.logID === 'function') return this.logID() 19 | return this.logID 20 | } 21 | } 22 | 23 | export default Logger 24 | -------------------------------------------------------------------------------- /src/utils/promise.ts: -------------------------------------------------------------------------------- 1 | export function assertFulfilled( 2 | item: PromiseSettledResult, 3 | ): item is PromiseFulfilledResult { 4 | return item.status === 'fulfilled' 5 | } 6 | 7 | export function assertRejected( 8 | item: PromiseSettledResult, 9 | ): item is PromiseRejectedResult { 10 | return item.status === 'rejected' 11 | } 12 | 13 | export function formatSettledErrors(settled: PromiseSettledResult[]) { 14 | if (settled.every(s => s.status === 'fulfilled')) return 15 | 16 | return settled.reduce((prev, curr, i) => { 17 | if (curr.status === 'rejected') { 18 | return prev + '\n' + `[${i}]: ` + `${JSON.stringify(curr)}` 19 | } 20 | return prev 21 | }, 'errors:\n') 22 | } 23 | 24 | export function createDeferredPromise() { 25 | let resolve: (value: T) => void 26 | let reject: (reason?: unknown) => void 27 | const promise = new Promise((res, rej) => { 28 | resolve = res 29 | reject = rej 30 | }) 31 | 32 | return { 33 | promise, 34 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 35 | reject: reject!, 36 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 37 | resolve: resolve!, 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/utils/wait.ts: -------------------------------------------------------------------------------- 1 | function wait(ms: number) { 2 | return new Promise(resolve => setTimeout(resolve, ms)) 3 | } 4 | 5 | export default wait 6 | -------------------------------------------------------------------------------- /test/performance.mjs: -------------------------------------------------------------------------------- 1 | import { writeFileSync } from 'fs' 2 | 3 | import { Session } from '../dist/cjs/index.js' 4 | 5 | const apiKey = process.env.API_KEY 6 | const reportSummaryFile = process.env.SUMMARY_FILE || './test.md' 7 | 8 | const codeSnippetIDs = ['Go', 'Nodejs'] 9 | const samplePerID = 3 10 | const upperBoundary = 1000 // 1s 11 | 12 | async function spinSession(id, isEditSession) { 13 | console.log('Creating session...') 14 | let session 15 | try { 16 | const startTime = performance.now() 17 | session = new Session({ 18 | id, 19 | // debug: true, 20 | editEnabled: isEditSession, 21 | ...(isEditSession && { apiKey }), 22 | }) 23 | await session.open() 24 | 25 | const endTime = performance.now() 26 | return endTime - startTime 27 | } catch (e) { 28 | console.error( 29 | `Measuring ${id}${isEditSession ? ' (persistent session)' : ''} failed`, 30 | e, 31 | ) 32 | } finally { 33 | ;(async () => { 34 | try { 35 | await session?.close() 36 | } catch (e) { 37 | // Do nothing 38 | } 39 | })() 40 | } 41 | 42 | throw new Error('**Measurement failed**') 43 | } 44 | 45 | function createReport(data, time) { 46 | let template = `# Devbook SDK - Session Performance 47 | 48 | *${time}* 49 | 50 | ## Results 51 | 52 | | Test | Samples | Result | 53 | | ------------- | ------------- | ------------- |` 54 | 55 | const createRow = (key, value) => { 56 | template = template + `\n| ${key} | ${value.size} | ${value.result} |` 57 | } 58 | 59 | Object.entries(data).forEach(e => createRow(e[0], e[1])) 60 | 61 | return template 62 | } 63 | 64 | function writeMeasurements(data) { 65 | const time = new Date(Date.now()) 66 | 67 | const report = createReport(data, time) 68 | writeFileSync(reportSummaryFile, report) 69 | } 70 | 71 | async function sample(id, size, isEditSession) { 72 | if (isEditSession && !apiKey) { 73 | console.log('No API key, skipping measuring persistent sessions') 74 | return {} 75 | } 76 | 77 | let totalTime = 0 78 | const entryName = `${isEditSession ? 'Persistent' : 'Public'} session (${id})` 79 | 80 | try { 81 | for (let i = 0; i < size; i++) { 82 | const timeToSession = await spinSession(id, isEditSession) 83 | totalTime += timeToSession 84 | } 85 | 86 | const averageTime = Math.round(totalTime / size) 87 | 88 | return { 89 | [entryName]: { 90 | result: `${averageTime}ms ${ 91 | averageTime < upperBoundary ? ':heavy_check_mark:' : ':x:' 92 | }`, 93 | size, 94 | }, 95 | } 96 | } catch (e) { 97 | return { 98 | [entryName]: { 99 | size, 100 | result: e.message, 101 | }, 102 | } 103 | } 104 | } 105 | 106 | async function main() { 107 | let entries = {} 108 | 109 | for (const id of codeSnippetIDs) { 110 | const entry = await sample(id, samplePerID) 111 | entries = { ...entries, ...entry } 112 | } 113 | 114 | // for (const id of codeSnippetIDs) { 115 | // // We do only one sample of persistent session because otherview we would get reconnected to the same session 116 | // const entry = await sample(id, 1, true) 117 | // entries = { ...entries, ...entry } 118 | // } 119 | 120 | writeMeasurements(entries) 121 | } 122 | 123 | main() 124 | -------------------------------------------------------------------------------- /test/run.mjs: -------------------------------------------------------------------------------- 1 | import { FilesystemOperation, FilesystemWatcher, Session } from '../dist/cjs/index.js' 2 | 3 | async function main() { 4 | const session = new Session({ 5 | id: 'Nodejs', 6 | debug: true, 7 | // codeSnippet: { 8 | // onStateChange(state) { 9 | // console.log(state) 10 | // }, 11 | // onStdout(out) { 12 | // console.log(out) 13 | // }, 14 | // onStderr(err) { 15 | // console.log(err) 16 | // }, 17 | // }, 18 | onDisconnect() { 19 | console.log('disconnect') 20 | }, 21 | onReconnect() { 22 | console.log('reconnect') 23 | }, 24 | onClose() { 25 | console.log('close') 26 | }, 27 | __debug_hostname: 'localhost', 28 | __debug_devEnv: 'local', 29 | }) 30 | 31 | try { 32 | await session.open() 33 | 34 | const dirWatchers = new Map() 35 | 36 | 37 | const w2 = session.filesystem.watchDir('/code/dir') 38 | dirWatchers.set('/code/dir', w2) 39 | await w2.start() 40 | w2.addEventListener(fsevent => { 41 | console.log('w2', fsevent) 42 | //if (fsevent.operation === FilesystemOperation.Remove) { 43 | // // Remove and stop watcher for a dir that got removed. 44 | // const dirwatcher = dirWatchers.get(fsevent.path) 45 | // if (dirwatcher) { 46 | // dirwatcher.stop() 47 | // dirWatchers.delete(fsevent.path) 48 | // } 49 | //} 50 | }) 51 | 52 | const w3 = session.filesystem.watchDir('/code/dir/subdir') 53 | dirWatchers.set('/code/dir/subdir', w3) 54 | await w3.start() 55 | w3.addEventListener(fsevent => { 56 | console.log('w3', fsevent) 57 | //if (fsevent.operation === FilesystemOperation.Remove && fsevent.path === '/code/dir/subdir') { 58 | // w3.stop() 59 | //} 60 | }) 61 | } catch (e) { 62 | console.error('Session error', e) 63 | } 64 | } 65 | 66 | main() 67 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "target": "es6", 5 | "lib": ["dom", "ESNext"], 6 | "sourceMap": true, 7 | "allowJs": true, 8 | "skipLibCheck": true, 9 | "esModuleInterop": true, 10 | "allowSyntheticDefaultImports": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noFallthroughCasesInSwitch": true, 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "declaration": true 18 | }, 19 | "include": ["src"], 20 | "exclude": ["dist", "node_modules", "rollup.config.js"] 21 | } 22 | --------------------------------------------------------------------------------