-
Notifications
You must be signed in to change notification settings - Fork 22
Feat: Persist and synchronize terminals #234
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
69550ce
666a8a2
b182faf
49790c4
2caa727
74a051a
cab4fbc
e1a8bb6
625880f
9ca0d56
3f525d8
8ae99a6
63d6b8a
e9ab395
0ebbcc6
553c3b6
f9349b0
0938202
0adad6a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,19 @@ | ||
| import { Socket } from "socket.io" | ||
| import { ConnectionManager } from "./ConnectionManager" | ||
| import { LockManager } from "../utils/lock" | ||
| import { Project } from "./Project" | ||
|
|
||
| function broadcastToProject( | ||
| connections: ConnectionManager, | ||
| projectId: string, | ||
| event: string, | ||
| payload: unknown, | ||
| ) { | ||
| connections.connectionsForProject(projectId).forEach((s: Socket) => { | ||
| s.emit(event, payload) | ||
| }) | ||
| } | ||
|
|
||
| type ServerContext = { | ||
| dokkuClient: any | null | ||
| gitClient: any | null | ||
|
|
@@ -19,9 +31,11 @@ export const createProjectHandlers = ( | |
| project: Project, | ||
| connection: ConnectionInfo, | ||
| context: ServerContext, | ||
| connections: ConnectionManager, | ||
| ) => { | ||
| const { dokkuClient, gitClient } = context | ||
| const lockManager = new LockManager() | ||
| const projectId = project.projectId | ||
|
|
||
| // Extract port number from a string | ||
| function extractPortNumber(inputString: string): number | null { | ||
|
|
@@ -93,33 +107,38 @@ export const createProjectHandlers = ( | |
| }: { | ||
| id: string | ||
| }) => { | ||
| await lockManager.acquireLock(project.projectId, async () => { | ||
| await lockManager.acquireLock(projectId, async () => { | ||
| await project.terminalManager?.createTerminal( | ||
| id, | ||
| (responseString: string) => { | ||
| connection.socket.emit("terminalResponse", { | ||
| broadcastToProject(connections, projectId, "terminalResponse", { | ||
| id, | ||
| data: responseString, | ||
| }) | ||
| const port = extractPortNumber(responseString) | ||
| if (port) { | ||
| connection.socket.emit( | ||
| "previewURL", | ||
| "https://" + project.container?.getHost(port), | ||
| ) | ||
| if (port && project.container && !project.previewURL) { | ||
| const url = "https://" + project.container.getHost(port) | ||
| project.setPreview(url, id) | ||
| broadcastToProject(connections, projectId, "previewState", { | ||
| url, | ||
| runTerminalId: id, | ||
| }) | ||
| } | ||
| }, | ||
| ) | ||
| broadcastToProject(connections, projectId, "terminalCreated", { id }) | ||
| }) | ||
| } | ||
|
|
||
| // Handle resizing a terminal | ||
| // Handle resizing a terminal (per-terminal dimensions) | ||
| const handleResizeTerminal: SocketHandler = ({ | ||
| id, | ||
| dimensions, | ||
| }: { | ||
| id: string | ||
| dimensions: { cols: number; rows: number } | ||
| }) => { | ||
| project.terminalManager?.resizeTerminal(dimensions) | ||
| project.terminalManager?.resizeTerminal(id, dimensions) | ||
| } | ||
|
|
||
| // Handle sending data to a terminal | ||
|
|
@@ -134,8 +153,43 @@ export const createProjectHandlers = ( | |
| } | ||
|
|
||
| // Handle closing a terminal | ||
| const handleCloseTerminal: SocketHandler = ({ id }: { id: string }) => { | ||
| return project.terminalManager?.closeTerminal(id) | ||
| const handleCloseTerminal: SocketHandler = async ({ id }: { id: string }) => { | ||
| const wasRunTerminal = project.runTerminalId === id | ||
| await project.terminalManager?.closeTerminal(id) | ||
| broadcastToProject(connections, projectId, "terminalClosed", { id }) | ||
| if (wasRunTerminal) { | ||
| project.clearPreview() | ||
| broadcastToProject(connections, projectId, "previewState", { | ||
| url: null, | ||
| runTerminalId: null, | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| // Send initial synced state to the requesting client (called when client receives "ready" to avoid race with listener setup) | ||
| const handleGetInitialState: SocketHandler = () => { | ||
| const ids = project.terminalManager?.getTerminalIds() ?? [] | ||
| const screens = | ||
| project.terminalManager?.getScreenBuffers?.() ?? undefined | ||
| connection.socket.emit("terminalState", { ids, screens }) | ||
| connection.socket.emit("previewState", { | ||
| url: project.previewURL, | ||
| runTerminalId: project.runTerminalId, | ||
| }) | ||
| } | ||
|
|
||
| // Handle stopping the preview server (kills dev server, closes preview terminal) | ||
| const handleStopPreview: SocketHandler = async () => { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This calls |
||
| const runId = project.runTerminalId | ||
| if (!runId) return | ||
| await project.killDevServers() | ||
| await project.terminalManager?.closeTerminal(runId) | ||
| project.clearPreview() | ||
| broadcastToProject(connections, projectId, "terminalClosed", { id: runId }) | ||
| broadcastToProject(connections, projectId, "previewState", { | ||
| url: null, | ||
| runTerminalId: null, | ||
| }) | ||
| } | ||
|
|
||
| // Return all handlers as a map of event names to handler functions | ||
|
|
@@ -148,5 +202,7 @@ export const createProjectHandlers = ( | |
| resizeTerminal: handleResizeTerminal, | ||
| terminalData: handleTerminalData, | ||
| closeTerminal: handleCloseTerminal, | ||
| stopPreview: handleStopPreview, | ||
| getInitialState: handleGetInitialState, | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,12 @@ | ||
| import { Sandbox as Container } from "e2b" | ||
| import { Terminal } from "./Terminal" | ||
|
|
||
| const MAX_SCREEN_BUFFER_CHARS = 50_000 | ||
|
|
||
| export class TerminalManager { | ||
| private container: Container | ||
| private terminals: Record<string, Terminal> = {} | ||
| private screenBuffers: Record<string, string> = {} | ||
|
|
||
| constructor(container: Container) { | ||
| this.container = container | ||
|
|
@@ -17,9 +20,22 @@ export class TerminalManager { | |
| return | ||
| } | ||
|
|
||
| this.screenBuffers[id] = "" | ||
| const pushData = (responseString: string) => { | ||
| let buf = this.screenBuffers[id] | ||
| if (buf !== undefined) { | ||
| buf += responseString | ||
| if (buf.length > MAX_SCREEN_BUFFER_CHARS) { | ||
| buf = buf.slice(-MAX_SCREEN_BUFFER_CHARS) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This cut could land in the middle of an ANSI escape sequence like |
||
| } | ||
| this.screenBuffers[id] = buf | ||
| } | ||
| onData(responseString) | ||
| } | ||
|
|
||
| this.terminals[id] = new Terminal(this.container) | ||
| await this.terminals[id].init({ | ||
| onData, | ||
| onData: pushData, | ||
| cols: 80, | ||
| rows: 20, | ||
| }) | ||
|
|
@@ -37,13 +53,11 @@ export class TerminalManager { | |
| console.log("Created terminal", id) | ||
| } | ||
|
|
||
| async resizeTerminal(dimensions: { | ||
| cols: number | ||
| rows: number | ||
| }): Promise<void> { | ||
| Object.values(this.terminals).forEach((t) => { | ||
| t.resize(dimensions) | ||
| }) | ||
| async resizeTerminal( | ||
| id: string, | ||
| dimensions: { cols: number; rows: number }, | ||
| ): Promise<void> { | ||
| this.terminals[id]?.resize(dimensions) | ||
| } | ||
|
|
||
| async sendTerminalData(id: string, data: string): Promise<void> { | ||
|
|
@@ -61,14 +75,29 @@ export class TerminalManager { | |
|
|
||
| await this.terminals[id].close() | ||
| delete this.terminals[id] | ||
| delete this.screenBuffers[id] | ||
| } | ||
|
|
||
| async closeAllTerminals(): Promise<void> { | ||
| await Promise.all( | ||
| Object.entries(this.terminals).map(async ([key, terminal]) => { | ||
| await terminal.close() | ||
| delete this.terminals[key] | ||
| delete this.screenBuffers[key] | ||
| }), | ||
| ) | ||
| } | ||
|
|
||
| getTerminalIds(): string[] { | ||
| return Object.keys(this.terminals) | ||
| } | ||
|
|
||
| getScreenBuffers(): Record<string, string> { | ||
| const out: Record<string, string> = {} | ||
| for (const id of Object.keys(this.terminals)) { | ||
| const buf = this.screenBuffers[id] | ||
| if (buf) out[id] = buf | ||
| } | ||
| return out | ||
| } | ||
| } | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
!project.previewURLguard means if the dev server restarts on a different port (3001 after 3000 was busy), the new URL gets silently ignored. User sees a dead preview with no way to fix it besides refreshing.