Skip to content

Commit fb7ce79

Browse files
committed
fix(api): run web terminal pty through node helper
1 parent 6678e32 commit fb7ce79

7 files changed

Lines changed: 310 additions & 33 deletions

File tree

bun.lock

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/api/src/services/auth-terminal-sessions.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ import { randomUUID } from "node:crypto"
55
import { fileURLToPath } from "node:url"
66
import type { IncomingMessage, Server as HttpServer } from "node:http"
77
import type { Duplex } from "node:stream"
8-
import { spawn, type IPty } from "node-pty"
98
import { WebSocket, WebSocketServer, type RawData } from "ws"
109

1110
import type { AuthTerminalFlow, AuthTerminalSessionRequest, TerminalSession, TerminalSessionStatus } from "../api/contracts.js"
1211
import { ApiConflictError, ApiNotFoundError, describeUnknown } from "../api/errors.js"
12+
import { spawnPtyBridge, type PtyBridge } from "./pty-bridge.js"
1313

1414
type TerminalClientMessage =
1515
| { readonly type: "input"; readonly data: string }
@@ -26,7 +26,7 @@ type AuthTerminalRecord = {
2626
attachTimeout: ReturnType<typeof setTimeout> | null
2727
args: ReadonlyArray<string>
2828
cwd: string
29-
pty: IPty | null
29+
pty: PtyBridge | null
3030
session: TerminalSession
3131
socket: WebSocket | null
3232
}
@@ -35,6 +35,7 @@ const attachTimeoutMs = 30_000
3535
const authTerminalProjectId = "__controller__"
3636
const authTerminalWsPathPattern = /^(?:\/api)?\/auth\/terminal-sessions\/([^/]+)\/ws$/u
3737
const authRunnerPath = fileURLToPath(new URL("../auth-terminal-runner.js", import.meta.url))
38+
const nodeCommand = process.env["DOCKER_GIT_NODE_BINARY"]?.trim() || "node"
3839
const records = new Map<string, AuthTerminalRecord>()
3940

4041
const TerminalClientMessageSchema = Schema.parseJson(
@@ -148,7 +149,7 @@ const decodeClientMessage = (raw: RawData): TerminalClientMessage | null =>
148149
const clampTerminalSize = (value: number, fallback: number): number =>
149150
Number.isFinite(value) && value > 0 ? Math.max(1, Math.floor(value)) : fallback
150151

151-
const writePtyInput = (pty: IPty | null, data: string): void => {
152+
const writePtyInput = (pty: PtyBridge | null, data: string): void => {
152153
if (pty === null) {
153154
return
154155
}
@@ -159,7 +160,7 @@ const writePtyInput = (pty: IPty | null, data: string): void => {
159160
}
160161
}
161162

162-
const resizePty = (pty: IPty | null, cols: number, rows: number): void => {
163+
const resizePty = (pty: PtyBridge | null, cols: number, rows: number): void => {
163164
if (pty === null) {
164165
return
165166
}
@@ -171,14 +172,11 @@ const resizePty = (pty: IPty | null, cols: number, rows: number): void => {
171172
}
172173

173174
const startTerminalPty = (record: AuthTerminalRecord, cols: number, rows: number): void => {
174-
const pty = spawn(process.execPath, [...record.args], {
175+
const pty = spawnPtyBridge({
176+
args: record.args,
175177
cols: clampTerminalSize(cols, 120),
178+
command: nodeCommand,
176179
cwd: record.cwd,
177-
env: {
178-
...process.env,
179-
TERM: "xterm-256color"
180-
},
181-
name: "xterm-256color",
182180
rows: clampTerminalSize(rows, 32)
183181
})
184182
record.pty = pty
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { spawn } from "node:child_process"
2+
import { fileURLToPath } from "node:url"
3+
4+
export type PtyExit = {
5+
readonly exitCode: number | null
6+
readonly signal: number | null
7+
}
8+
9+
export type PtyBridge = {
10+
readonly kill: () => void
11+
readonly onData: (handler: (data: string) => void) => void
12+
readonly onExit: (handler: (exit: PtyExit) => void) => void
13+
readonly resize: (cols: number, rows: number) => void
14+
readonly write: (data: string) => void
15+
}
16+
17+
type PtyBridgeSpec = {
18+
readonly args: ReadonlyArray<string>
19+
readonly cols: number
20+
readonly command: string
21+
readonly cwd: string
22+
readonly rows: number
23+
}
24+
25+
type HelperOutputMessage =
26+
| { readonly type: "data"; readonly data: string }
27+
| { readonly type: "error"; readonly message: string }
28+
| { readonly type: "exit"; readonly exitCode: number | null; readonly signal: number | null }
29+
30+
const helperPath = fileURLToPath(new URL("./pty-helper.js", import.meta.url))
31+
const nodeCommand = process.env["DOCKER_GIT_NODE_BINARY"]?.trim() || "node"
32+
33+
const encodeStartSpec = (spec: PtyBridgeSpec): string =>
34+
Buffer.from(JSON.stringify(spec), "utf8").toString("base64url")
35+
36+
const encodeInput = (data: string): string => Buffer.from(data, "utf8").toString("base64")
37+
38+
const encodeCommand = (message: object): string => `${JSON.stringify(message)}\n`
39+
40+
const decodeOutputMessage = (line: string): HelperOutputMessage | null => {
41+
try {
42+
return JSON.parse(line) as HelperOutputMessage
43+
} catch {
44+
return null
45+
}
46+
}
47+
48+
const decodeOutputData = (data: string): string => Buffer.from(data, "base64").toString("utf8")
49+
50+
export const spawnPtyBridge = (spec: PtyBridgeSpec): PtyBridge => {
51+
const child = spawn(nodeCommand, [helperPath, encodeStartSpec(spec)], {
52+
cwd: spec.cwd,
53+
env: {
54+
...process.env,
55+
TERM: "xterm-256color"
56+
},
57+
stdio: ["pipe", "pipe", "pipe"]
58+
})
59+
const dataHandlers = new Set<(data: string) => void>()
60+
const exitHandlers = new Set<(exit: PtyExit) => void>()
61+
let exitSeen = false
62+
let pending = ""
63+
64+
const emitData = (data: string): void => {
65+
for (const handler of dataHandlers) {
66+
handler(data)
67+
}
68+
}
69+
const emitExit = (exit: PtyExit): void => {
70+
if (exitSeen) {
71+
return
72+
}
73+
exitSeen = true
74+
for (const handler of exitHandlers) {
75+
handler(exit)
76+
}
77+
}
78+
const sendCommand = (message: object): void => {
79+
if (!child.stdin.destroyed) {
80+
child.stdin.write(encodeCommand(message))
81+
}
82+
}
83+
84+
child.stdout.setEncoding("utf8")
85+
child.stdout.on("data", (chunk) => {
86+
pending += chunk
87+
const lines = pending.split("\n")
88+
pending = lines.pop() ?? ""
89+
for (const line of lines) {
90+
const message = decodeOutputMessage(line)
91+
if (message === null) {
92+
continue
93+
}
94+
if (message.type === "data") {
95+
emitData(decodeOutputData(message.data))
96+
} else if (message.type === "error") {
97+
emitData(`\r\n[pty helper error] ${message.message}\r\n`)
98+
} else {
99+
emitExit({ exitCode: message.exitCode, signal: message.signal })
100+
}
101+
}
102+
})
103+
child.stderr.on("data", (chunk) => {
104+
emitData(`\r\n[pty helper stderr] ${String(chunk)}\r\n`)
105+
})
106+
child.on("exit", (exitCode) => {
107+
emitExit({ exitCode, signal: null })
108+
})
109+
110+
return {
111+
kill: () => {
112+
sendCommand({ type: "kill" })
113+
child.kill()
114+
},
115+
onData: (handler) => {
116+
dataHandlers.add(handler)
117+
},
118+
onExit: (handler) => {
119+
exitHandlers.add(handler)
120+
},
121+
resize: (cols, rows) => {
122+
sendCommand({ type: "resize", cols, rows })
123+
},
124+
write: (data) => {
125+
sendCommand({ type: "input", data: encodeInput(data) })
126+
}
127+
}
128+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { spawn } from "node-pty"
2+
3+
type PtyStartSpec = {
4+
readonly args: ReadonlyArray<string>
5+
readonly cols: number
6+
readonly command: string
7+
readonly cwd: string
8+
readonly rows: number
9+
}
10+
11+
type HelperInputMessage =
12+
| { readonly type: "input"; readonly data: string }
13+
| { readonly type: "resize"; readonly cols: number; readonly rows: number }
14+
| { readonly type: "kill" }
15+
16+
type HelperOutputMessage =
17+
| { readonly type: "data"; readonly data: string }
18+
| { readonly type: "error"; readonly message: string }
19+
| { readonly type: "exit"; readonly exitCode: number | null; readonly signal: number | null }
20+
21+
const encodeMessage = (message: HelperOutputMessage): string => `${JSON.stringify(message)}\n`
22+
23+
const sendMessage = (message: HelperOutputMessage): void => {
24+
process.stdout.write(encodeMessage(message))
25+
}
26+
27+
const decodeStartSpec = (): PtyStartSpec => {
28+
const encoded = process.argv[2]
29+
if (encoded === undefined) {
30+
throw new Error("Missing PTY start spec.")
31+
}
32+
return JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as PtyStartSpec
33+
}
34+
35+
const decodeInputMessage = (line: string): HelperInputMessage | null => {
36+
try {
37+
return JSON.parse(line) as HelperInputMessage
38+
} catch {
39+
return null
40+
}
41+
}
42+
43+
const sendData = (data: string): void => {
44+
sendMessage({ type: "data", data: Buffer.from(data, "utf8").toString("base64") })
45+
}
46+
47+
const main = (): void => {
48+
const spec = decodeStartSpec()
49+
const pty = spawn(spec.command, [...spec.args], {
50+
cols: spec.cols,
51+
cwd: spec.cwd,
52+
env: {
53+
...process.env,
54+
TERM: "xterm-256color"
55+
},
56+
name: "xterm-256color",
57+
rows: spec.rows
58+
})
59+
60+
pty.onData(sendData)
61+
pty.onExit(({ exitCode, signal }) => {
62+
sendMessage({ type: "exit", exitCode: exitCode ?? null, signal: signal ?? null })
63+
})
64+
65+
let pending = ""
66+
process.stdin.setEncoding("utf8")
67+
process.stdin.on("data", (chunk) => {
68+
pending += chunk
69+
const lines = pending.split("\n")
70+
pending = lines.pop() ?? ""
71+
for (const line of lines) {
72+
const message = decodeInputMessage(line)
73+
if (message === null) {
74+
continue
75+
}
76+
if (message.type === "input") {
77+
pty.write(Buffer.from(message.data, "base64").toString("utf8"))
78+
} else if (message.type === "resize") {
79+
pty.resize(message.cols, message.rows)
80+
} else {
81+
pty.kill()
82+
}
83+
}
84+
})
85+
}
86+
87+
try {
88+
main()
89+
} catch (error) {
90+
sendMessage({ type: "error", message: String(error) })
91+
sendMessage({ type: "exit", exitCode: 1, signal: null })
92+
}

packages/api/src/services/terminal-sessions.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@ import { Effect, Either } from "effect"
1010
import { randomUUID } from "node:crypto"
1111
import type { IncomingMessage, Server as HttpServer } from "node:http"
1212
import type { Duplex } from "node:stream"
13-
import { spawn, type IPty } from "node-pty"
1413
import { WebSocket, WebSocketServer, type RawData } from "ws"
1514

1615
import type { TerminalSession, TerminalSessionStatus } from "../api/contracts.js"
1716
import { ApiConflictError, ApiInternalError, ApiNotFoundError, describeUnknown } from "../api/errors.js"
17+
import { spawnPtyBridge, type PtyBridge } from "./pty-bridge.js"
1818
import { emitProjectEvent } from "./events.js"
1919
import { getProjectItemById, upProject } from "./projects.js"
2020

@@ -31,7 +31,7 @@ type TerminalServerMessage =
3131

3232
type TerminalRecord = {
3333
session: TerminalSession
34-
pty: IPty | null
34+
pty: PtyBridge | null
3535
socket: WebSocket | null
3636
attachTimeout: ReturnType<typeof setTimeout> | null
3737
projectId: string
@@ -241,7 +241,7 @@ const decodeClientMessage = (raw: RawData): TerminalClientMessage | null =>
241241
const clampTerminalSize = (value: number, fallback: number): number =>
242242
Number.isFinite(value) && value > 0 ? Math.max(1, Math.floor(value)) : fallback
243243

244-
const writePtyInput = (pty: IPty | null, data: string): void => {
244+
const writePtyInput = (pty: PtyBridge | null, data: string): void => {
245245
if (pty === null) {
246246
return
247247
}
@@ -252,7 +252,7 @@ const writePtyInput = (pty: IPty | null, data: string): void => {
252252
}
253253
}
254254

255-
const resizePty = (pty: IPty | null, cols: number, rows: number): void => {
255+
const resizePty = (pty: PtyBridge | null, cols: number, rows: number): void => {
256256
if (pty === null) {
257257
return
258258
}
@@ -270,14 +270,11 @@ const startTerminalPty = (
270270
): void => {
271271
const resolvedCols = clampTerminalSize(cols, 120)
272272
const resolvedRows = clampTerminalSize(rows, 32)
273-
const pty = spawn(record.prepared.command, [...record.prepared.args], {
273+
const pty = spawnPtyBridge({
274+
args: record.prepared.args,
275+
command: record.prepared.command,
274276
cols: resolvedCols,
275277
cwd: record.prepared.cwd,
276-
env: {
277-
...process.env,
278-
TERM: "xterm-256color"
279-
},
280-
name: "xterm-256color",
281278
rows: resolvedRows
282279
})
283280
record.pty = pty

packages/app/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@
9898
"@types/node": "^24.12.0",
9999
"@types/react": "^19.2.14",
100100
"@types/react-dom": "^19.2.3",
101+
"@types/ws": "^8.18.1",
101102
"@typescript-eslint/eslint-plugin": "^8.57.1",
102103
"@typescript-eslint/parser": "^8.57.1",
103104
"@vitejs/plugin-react": "^6.0.1",
@@ -117,6 +118,7 @@
117118
"typescript": "^5.9.3",
118119
"typescript-eslint": "^8.57.1",
119120
"vite": "^8.0.1",
120-
"vitest": "^4.1.0"
121+
"vitest": "^4.1.0",
122+
"ws": "^8.20.0"
121123
}
122124
}

0 commit comments

Comments
 (0)