@devbookhq/sdk
Version:
SDK for managing Devbook sessions from JavaScript/TypeScript
369 lines (319 loc) • 11 kB
text/typescript
import { IRpcNotification, RpcWebSocketClient } from 'rpc-websocket-client'
import api, { components } from '../api'
import {
SESSION_DOMAIN,
SESSION_REFRESH_PERIOD,
WS_PORT,
WS_RECONNECT_INTERVAL,
WS_ROUTE,
} from '../constants'
import Logger from '../utils/logger'
import { assertFulfilled, formatSettledErrors } from '../utils/promise'
import wait from '../utils/wait'
import { codeSnippetService } from './codeSnippet'
import { filesystemService } from './filesystem'
import { processService } from './process'
import { terminalService } from './terminal'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type SubscriptionHandler = (result: any) => void
type Service =
| typeof processService
| typeof codeSnippetService
| typeof filesystemService
| typeof terminalService
interface Subscriber {
service: Service
subID: string
handler: SubscriptionHandler
}
export type CloseHandler = () => void
export type DisconnectHandler = () => void
export type ReconnectHandler = () => void
export interface SessionConnectionOpts {
id: string
apiKey?: string
onClose?: CloseHandler
onDisconnect?: DisconnectHandler
onReconnect?: ReconnectHandler
debug?: boolean
editEnabled?: boolean
__debug_hostname?: string
__debug_port?: number
__debug_devEnv?: 'remote' | 'local'
}
const createSession = api.path('/sessions').method('post').create({ api_key: true })
const refreshSession = api
.path('/sessions/{sessionID}/refresh')
.method('post')
.create({ api_key: true })
abstract class SessionConnection {
protected readonly logger: Logger
protected session?: components['schemas']['Session']
protected isOpen = false
private readonly rpc = new RpcWebSocketClient()
private subscribers: Subscriber[] = []
constructor(private readonly opts: SessionConnectionOpts) {
this.logger = new Logger('Session', opts.debug)
this.logger.log(`Session for code snippet "${opts.id}" initialized`)
}
/**
* Get the hostname for the session or for the specified session's port.
*
* `getHostname` method requires `this` context - you may need to bind it.
*
* @param port specify if you want to connect to a specific port of the session
* @returns hostname of the session or session's port
*/
getHostname(port?: number) {
if (this.opts.__debug_hostname) {
// Debugging remotely (with GitPod) and on local needs different formats of the hostname.
if (port && this.opts.__debug_devEnv === 'remote') {
return `${port}-${this.opts.__debug_hostname}`
} else if (port) {
return `${this.opts.__debug_hostname}:${port}`
} else {
return this.opts.__debug_hostname
}
}
if (!this.session) {
return undefined
}
const hostname = `${this.session.sessionID}-${this.session.clientID}.${SESSION_DOMAIN}`
if (port) {
return `${port}-${hostname}`
} else {
return hostname
}
}
/**
* Close the connection to the session
*
* `close` method requires `this` context - you may need to bind it.
*/
async close() {
if (this.isOpen) {
this.logger.log('Closing', this.session)
this.isOpen = false
this.logger.log('Unsubscribing...')
const results = await Promise.allSettled(
this.subscribers.map(s => this.unsubscribe(s.subID)),
)
results.forEach(r => {
if (r.status === 'rejected') {
this.logger.log(`Failed to unsubscribe: "${r.reason}"`)
}
})
// This is `ws` way of closing connection
this.rpc.ws?.terminate?.()
// This is the browser WebSocket way of closing connection
this.rpc.ws?.close?.()
this.opts?.onClose?.()
this.logger.log('Disconected from the session')
}
}
/**
* Open a connection to a new session
*
* `open` method requires `this` context - you may need to bind it.
*/
async open() {
if (this.isOpen || !!this.session) {
throw new Error('Session connect was already called')
} else {
this.isOpen = true
}
if (!this.opts.__debug_hostname) {
try {
const res = await createSession({
api_key: this.opts.apiKey,
codeSnippetID: this.opts.id,
editEnabled: this.opts.editEnabled,
})
this.session = res.data
this.logger.log('Aquired session:', this.session)
this.refresh(this.session.sessionID)
} catch (e) {
if (e instanceof createSession.Error) {
const error = e.getActualType()
if (error.status === 400) {
throw new Error(
`Error creating session - (${error.status}) bad request: ${error.data.message}`,
)
}
if (error.status === 401) {
throw new Error(
`Error creating session - (${error.status}) unauthenticated (you need to be authenticated to start an session with persistent edits): ${error.data.message}`,
)
}
if (error.status === 500) {
throw new Error(
`Error creating session - (${error.status}) server error: ${error.data.message}`,
)
}
}
throw e
}
}
const hostname = this.getHostname(this.opts.__debug_port || WS_PORT)
if (!hostname) {
throw new Error("Cannot get session's hostname")
}
const protocol = this.opts.__debug_devEnv === 'local' ? 'ws' : 'wss'
const sessionURL = `${protocol}://${hostname}${WS_ROUTE}`
this.rpc.onError(e => {
this.logger.log('Error in WS session:', this.session, e)
})
let isFinished = false
let resolveOpening: (() => void) | undefined
let rejectOpening: (() => void) | undefined
const openingPromise = new Promise<void>((resolve, reject) => {
resolveOpening = () => {
if (isFinished) return
isFinished = true
resolve()
}
rejectOpening = () => {
if (isFinished) return
isFinished = true
reject()
}
})
this.rpc.onOpen(() => {
this.logger.log('Connected to session:', this.session)
resolveOpening?.()
})
this.rpc.onClose(async e => {
this.logger.log('Closing WS connection to session:', this.session, e)
if (this.isOpen) {
this.opts.onDisconnect?.()
await wait(WS_RECONNECT_INTERVAL)
this.logger.log('Reconnecting to session:', this.session)
try {
// When the WS connection closes the subscribers in devbookd are removed.
// We want to delete the subscriber handlers here so there are no orphans.
this.subscribers = []
await this.rpc.connect(sessionURL)
this.opts.onReconnect?.()
this.logger.log('Reconnected to session:', this.session)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
this.logger.log('Failed reconnecting to session:', this.session, e)
}
} else {
rejectOpening?.()
}
})
this.rpc.onNotification.push(this.handleNotification.bind(this))
try {
this.logger.log('Connection to session:', this.session)
await this.rpc.connect(sessionURL)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
this.logger.log('Error connecting to session', this.session, e)
}
await openingPromise
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async call(service: Service, method: string, params?: any[]) {
return this.rpc.call(`${service}_${method}`, params)
}
async handleSubscriptions<
T extends (ReturnType<SessionConnection['subscribe']> | undefined)[],
>(
...subs: T
): Promise<{
[P in keyof T]: Awaited<T[P]>
}> {
const results = await Promise.allSettled(subs)
if (results.every(r => r.status === 'fulfilled')) {
return results.map(r => (r.status === 'fulfilled' ? r.value : undefined)) as {
[P in keyof T]: Awaited<T[P]>
}
}
await Promise.all(
results
.filter(assertFulfilled)
.map(r => (r.value ? this.unsubscribe(r.value) : undefined)),
)
throw new Error(formatSettledErrors(results))
}
async unsubscribe(subID: string) {
const subscription = this.subscribers.find(s => s.subID === subID)
if (!subscription) return
await this.call(subscription.service, 'unsubscribe', [subscription.subID])
this.subscribers = this.subscribers.filter(s => s !== subscription)
this.logger.log(`Unsubscribed '${subID}' from '${subscription.service}'`)
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async subscribe(
service: Service,
handler: SubscriptionHandler,
method: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...params: any[]
) {
const subID = await this.call(service, 'subscribe', [method, ...params])
if (typeof subID !== 'string') {
throw new Error(
`Cannot subscribe to ${service}_${method}${
params.length > 0 ? ' with params [' + params.join(', ') + ']' : ''
}. Expected response should have been a subscription ID, instead we got ${JSON.stringify(
subID,
)}`,
)
}
this.subscribers.push({
handler,
service,
subID,
})
this.logger.log(
`Subscribed to "${service}_${method}"${
params.length > 0 ? ' with params [' + params.join(', ') + '] and' : ''
} with id "${subID}"`,
)
return subID
}
private handleNotification(data: IRpcNotification) {
this.subscribers
.filter(s => s.subID === data.params?.subscription)
.forEach(s => s.handler(data.params?.result))
}
private async refresh(sessionID: string) {
this.logger.log(`Started refreshing session "${sessionID}"`)
try {
// eslint-disable-next-line no-constant-condition
while (true) {
if (!this.isOpen) {
this.logger.log('Cannot refresh session - it was closed', this.session)
return
}
await wait(SESSION_REFRESH_PERIOD)
try {
this.logger.log(`Refreshed session "${sessionID}"`)
await refreshSession({
api_key: this.opts.apiKey,
sessionID,
})
} catch (e) {
if (e instanceof refreshSession.Error) {
const error = e.getActualType()
if (error.status === 404) {
this.logger.error(
`Error refreshing session - (${error.status}): ${error.data.message}`,
)
return
}
this.logger.error(
`Refreshing session "${sessionID}" failed - (${error.status})`,
)
}
}
}
} finally {
this.logger.log(`Stopped refreshing session "${sessionID}"`)
this.close()
}
}
}
export default SessionConnection