import { logger } from "@coder/logger" import express from "express" import * as http from "http" import * as path from "path" import { HttpCode } from "../common/http" import { listen } from "./app" import { canConnect } from "./util" export interface EditorSessionEntry { workspace: { id: string folders: { uri: { path: string } }[] } socketPath: string } interface DeleteSessionRequest { socketPath: string } interface AddSessionRequest { entry: EditorSessionEntry } interface GetSessionResponse { socketPath?: string } export async function makeEditorSessionManagerServer( codeServerSocketPath: string, editorSessionManager: EditorSessionManager, ): Promise { const router = express() // eslint-disable-next-line import/no-named-as-default-member router.use(express.json()) router.get("/session", async (req, res) => { const filePath = req.query.filePath as string if (!filePath) { res.status(HttpCode.BadRequest).send("filePath is required") return } try { const socketPath = await editorSessionManager.getConnectedSocketPath(filePath) const response: GetSessionResponse = { socketPath } res.json(response) } catch (error: unknown) { res.status(HttpCode.ServerError).send(error) } }) router.post("/add-session", async (req, res) => { const request = req.body as AddSessionRequest if (!request.entry) { res.status(400).send("entry is required") } editorSessionManager.addSession(request.entry) res.status(200).send() }) router.post("/delete-session", async (req, res) => { const request = req.body as DeleteSessionRequest if (!request.socketPath) { res.status(400).send("socketPath is required") } editorSessionManager.deleteSession(request.socketPath) res.status(200).send() }) const server = http.createServer(router) try { await listen(server, { socket: codeServerSocketPath }) } catch (e) { logger.warn(`Could not create socket at ${codeServerSocketPath}`) } return server } export class EditorSessionManager { // Map from socket path to EditorSessionEntry. private entries = new Map() addSession(entry: EditorSessionEntry): void { logger.debug(`Adding session to session registry: ${entry.socketPath}`) this.entries.set(entry.socketPath, entry) } getCandidatesForFile(filePath: string): EditorSessionEntry[] { const matchCheckResults = new Map() const checkMatch = (entry: EditorSessionEntry): boolean => { if (matchCheckResults.has(entry.socketPath)) { return matchCheckResults.get(entry.socketPath)! } const result = entry.workspace.folders.some((folder) => filePath.startsWith(folder.uri.path + path.sep)) matchCheckResults.set(entry.socketPath, result) return result } return Array.from(this.entries.values()) .reverse() // Most recently registered first. .sort((a, b) => { // Matches first. const aMatch = checkMatch(a) const bMatch = checkMatch(b) if (aMatch === bMatch) { return 0 } if (aMatch) { return -1 } return 1 }) } deleteSession(socketPath: string): void { logger.debug(`Deleting session from session registry: ${socketPath}`) this.entries.delete(socketPath) } /** * Returns the best socket path that we can connect to. * We also delete any sockets that we can't connect to. */ async getConnectedSocketPath(filePath: string): Promise { const candidates = this.getCandidatesForFile(filePath) let match: EditorSessionEntry | undefined = undefined for (const candidate of candidates) { if (await canConnect(candidate.socketPath)) { match = candidate break } this.deleteSession(candidate.socketPath) } return match?.socketPath } } export class EditorSessionManagerClient { constructor(private codeServerSocketPath: string) {} async canConnect() { return canConnect(this.codeServerSocketPath) } async getConnectedSocketPath(filePath: string): Promise { const response = await new Promise((resolve, reject) => { const opts = { path: "/session?filePath=" + encodeURIComponent(filePath), socketPath: this.codeServerSocketPath, method: "GET", } const req = http.request(opts, (res) => { let rawData = "" res.setEncoding("utf8") res.on("data", (chunk) => { rawData += chunk }) res.on("end", () => { try { const obj = JSON.parse(rawData) if (res.statusCode === 200) { resolve(obj) } else { reject(new Error("Unexpected status code: " + res.statusCode)) } } catch (e: unknown) { reject(e) } }) }) req.on("error", reject) req.end() }) return response.socketPath } // Currently only used for tests. async addSession(request: AddSessionRequest): Promise { await new Promise((resolve, reject) => { const opts = { path: "/add-session", socketPath: this.codeServerSocketPath, method: "POST", headers: { "content-type": "application/json", accept: "application/json", }, } const req = http.request(opts, () => { resolve() }) req.on("error", reject) req.write(JSON.stringify(request)) req.end() }) } }