@devbookhq/sdk
Version:
SDK for managing Devbook sessions from JavaScript/TypeScript
304 lines (276 loc) • 9.57 kB
text/typescript
import normalizePath from 'normalize-path'
import { id } from '../utils/id'
import { createDeferredPromise, formatSettledErrors } from '../utils/promise'
import {
CodeSnippetExecState,
CodeSnippetManager,
CodeSnippetStateHandler,
CodeSnippetStderrHandler,
CodeSnippetStdoutHandler,
ScanOpenedPortsHandler,
codeSnippetService,
} from './codeSnippet'
import { FileInfo, FilesystemManager, filesystemService } from './filesystem'
import FilesystemWatcher from './filesystemWatcher'
import { ProcessManager, processService } from './process'
import SessionConnection, { SessionConnectionOpts } from './sessionConnection'
import { TerminalManager, terminalService } from './terminal'
export interface CodeSnippetOpts {
onStateChange?: CodeSnippetStateHandler
onStderr?: CodeSnippetStderrHandler
onStdout?: CodeSnippetStdoutHandler
onScanPorts?: ScanOpenedPortsHandler
}
export interface SessionOpts extends SessionConnectionOpts {
codeSnippet?: CodeSnippetOpts
}
class Session extends SessionConnection {
codeSnippet?: CodeSnippetManager
terminal?: TerminalManager
filesystem?: FilesystemManager
process?: ProcessManager
private readonly codeSnippetOpts?: CodeSnippetOpts
constructor(opts: SessionOpts) {
super(opts)
this.codeSnippetOpts = opts.codeSnippet
}
async open() {
await super.open()
await this.handleSubscriptions(
this.codeSnippetOpts?.onStateChange
? this.subscribe(codeSnippetService, this.codeSnippetOpts.onStateChange, 'state')
: undefined,
this.codeSnippetOpts?.onStderr
? this.subscribe(codeSnippetService, this.codeSnippetOpts.onStderr, 'stderr')
: undefined,
this.codeSnippetOpts?.onStdout
? this.subscribe(codeSnippetService, this.codeSnippetOpts.onStdout, 'stdout')
: undefined,
this.codeSnippetOpts?.onScanPorts
? this.subscribe(
codeSnippetService,
this.codeSnippetOpts.onScanPorts,
'scanOpenedPorts',
)
: undefined,
)
// Init CodeSnippet handler
this.codeSnippet = {
run: async (code, envVars = {}) => {
const state = (await this.call(codeSnippetService, 'run', [
code,
envVars,
])) as CodeSnippetExecState
this.codeSnippetOpts?.onStateChange?.(state)
return state
},
stop: async () => {
const state = (await this.call(
codeSnippetService,
'stop',
)) as CodeSnippetExecState
this.codeSnippetOpts?.onStateChange?.(state)
return state
},
}
// Init Filesystem handler
this.filesystem = {
/**
* List files in a directory.
* @param path path to a directory
* @returns Array of files in a directory
*/
list: async path => {
return (await this.call(filesystemService, 'list', [path])) as FileInfo[]
},
/**
* Reads the whole content of a file.
* @param path path to a file
* @returns Content of a file
*/
read: async path => {
return (await this.call(filesystemService, 'read', [path])) as string
},
/**
* Removes a file or a directory.
* @param path path to a file or a directory
*/
remove: async path => {
await this.call(filesystemService, 'remove', [path])
},
/**
* Writes content to a new file on path.
* @param path path to a new file. For example '/dirA/dirB/newFile.txt' when creating 'newFile.txt'
* @param content content to write to a new file
*/
write: async (path, content) => {
await this.call(filesystemService, 'write', [path, content])
},
/**
* Write array of bytes to a file.
* This can be used when you cannot represent the data as an UTF-8 string.
*
* @param path path to a file
* @param content byte array representing the content to write
*/
writeBytes: async (path, content) => {
// We need to convert the byte array to base64 string without using browser or node specific APIs.
// This should be achieved by the node polyfills.
const base64Content = Buffer.from(content).toString('base64')
await this.call(filesystemService, 'writeBase64', [path, base64Content])
},
/**
* Reads the whole content of a file as an array of bytes.
* @param path path to a file
* @returns byte array representing the content of a file
*/
readBytes: async path => {
const base64Content = (await this.call(filesystemService, 'readBase64', [
path,
])) as string
// We need to convert the byte array to base64 string without using browser or node specific APIs.
// This should be achieved by the node polyfills.
return Buffer.from(base64Content, 'base64')
},
/**
* Creates a new directory and all directories along the way if needed on the specified pth.
* @param path path to a new directory. For example '/dirA/dirB' when creating 'dirB'.
*/
makeDir: async path => {
await this.call(filesystemService, 'makeDir', [path])
},
/**
* Watches directory for filesystem events.
* @param path path to a directory that will be watched
* @returns new watcher
*/
watchDir: (path: string) => {
const npath = normalizePath(path)
return new FilesystemWatcher(this, npath)
},
}
// Init Terminal handler
this.terminal = {
createSession: async ({
onData,
size,
onExit,
envVars,
cmd,
rootdir,
terminalID = id(12),
}) => {
const { promise: terminalExited, resolve: triggerExit } = createDeferredPromise()
const [onDataSubID, onExitSubID] = await this.handleSubscriptions(
this.subscribe(terminalService, onData, 'onData', terminalID),
this.subscribe(terminalService, triggerExit, 'onExit', terminalID),
)
const { promise: unsubscribing, resolve: handleFinishUnsubscribing } =
createDeferredPromise()
terminalExited.then(async () => {
const results = await Promise.allSettled([
this.unsubscribe(onExitSubID),
this.unsubscribe(onDataSubID),
])
const errMsg = formatSettledErrors(results)
if (errMsg) {
this.logger.error(errMsg)
}
onExit?.()
handleFinishUnsubscribing()
})
try {
await this.call(terminalService, 'start', [
terminalID,
size.cols,
size.rows,
// Handle optional args for old devbookd compatibility
...(cmd !== undefined ? [envVars, cmd, rootdir] : []),
])
} catch (err) {
triggerExit()
await unsubscribing
throw err
}
return {
destroy: async () => {
try {
await this.call(terminalService, 'destroy', [terminalID])
} finally {
triggerExit()
await unsubscribing
}
},
resize: async ({ cols, rows }) => {
await this.call(terminalService, 'resize', [terminalID, cols, rows])
},
sendData: async data => {
await this.call(terminalService, 'data', [terminalID, data])
},
terminalID,
}
},
}
// Init Process handler
this.process = {
start: async ({
cmd,
onStdout,
onStderr,
onExit,
envVars = {},
rootdir = '/',
processID = id(12),
}) => {
const { promise: processExited, resolve: triggerExit } = createDeferredPromise()
const [onExitSubID, onStdoutSubID, onStderrSubID] =
await this.handleSubscriptions(
this.subscribe(processService, triggerExit, 'onExit', processID),
onStdout
? this.subscribe(processService, onStdout, 'onStdout', processID)
: undefined,
onStderr
? this.subscribe(processService, onStderr, 'onStderr', processID)
: undefined,
)
const { promise: unsubscribing, resolve: handleFinishUnsubscribing } =
createDeferredPromise()
processExited.then(async () => {
const results = await Promise.allSettled([
this.unsubscribe(onExitSubID),
onStdoutSubID ? this.unsubscribe(onStdoutSubID) : undefined,
onStderrSubID ? this.unsubscribe(onStderrSubID) : undefined,
])
const errMsg = formatSettledErrors(results)
if (errMsg) {
this.logger.error(errMsg)
}
onExit?.()
handleFinishUnsubscribing()
})
try {
await this.call(processService, 'start', [processID, cmd, envVars, rootdir])
} catch (err) {
triggerExit()
await unsubscribing
throw err
}
return {
kill: async () => {
try {
await this.call(processService, 'kill', [processID])
} finally {
triggerExit()
await unsubscribing
}
},
processID,
sendStdin: async data => {
await this.call(processService, 'stdin', [processID, data])
},
}
},
}
}
}
export default Session