cli.js 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. import { createInterface } from "node:readline";
  2. import { ConfigurationError, parseEndpoint, validateInvocation, } from "./config.js";
  3. const STARTUP_TIMEOUT_MS = 5_000;
  4. function writeDiagnostic(output, message) {
  5. output.write(`codex-app-server-bridge: ${message}\n`);
  6. }
  7. export async function runBridge(options) {
  8. try {
  9. validateInvocation(options.arguments);
  10. }
  11. catch (error) {
  12. const message = error instanceof ConfigurationError ? error.message : "invalid invocation";
  13. writeDiagnostic(options.errorOutput, message);
  14. return 1;
  15. }
  16. let endpoint;
  17. try {
  18. endpoint = parseEndpoint(options.environment.CODEX_APP_SERVER_URL);
  19. }
  20. catch (error) {
  21. const message = error instanceof ConfigurationError ? error.message : "invalid configuration";
  22. writeDiagnostic(options.errorOutput, `configuration error: ${message}`);
  23. return 1;
  24. }
  25. return await new Promise((resolve) => {
  26. let socket;
  27. let lineReader;
  28. let completed = false;
  29. let localShutdown = false;
  30. let failure = false;
  31. let startupTimer;
  32. const removeProcessListeners = () => {
  33. process.removeListener("SIGINT", handleSignal);
  34. process.removeListener("SIGTERM", handleSignal);
  35. };
  36. const finish = (exitCode) => {
  37. if (completed) {
  38. return;
  39. }
  40. completed = true;
  41. if (startupTimer !== undefined) {
  42. clearTimeout(startupTimer);
  43. }
  44. lineReader?.close();
  45. options.input.pause();
  46. removeProcessListeners();
  47. resolve(exitCode);
  48. };
  49. const closeSocket = (code = 1000, reason = "bridge shutdown") => {
  50. if (socket.readyState === WebSocket.OPEN) {
  51. socket.close(code, reason);
  52. }
  53. else if (socket.readyState === WebSocket.CONNECTING) {
  54. try {
  55. socket.close();
  56. }
  57. catch {
  58. // Process completion still closes a connection that has not opened.
  59. }
  60. }
  61. };
  62. const fail = (message, closeCode = 1011) => {
  63. if (failure || completed) {
  64. return;
  65. }
  66. failure = true;
  67. writeDiagnostic(options.errorOutput, message);
  68. closeSocket(closeCode, "bridge failure");
  69. finish(1);
  70. };
  71. const beginLocalShutdown = () => {
  72. if (completed || localShutdown) {
  73. return;
  74. }
  75. localShutdown = true;
  76. if (startupTimer !== undefined) {
  77. clearTimeout(startupTimer);
  78. }
  79. lineReader?.close();
  80. options.input.pause();
  81. closeSocket();
  82. if (socket.readyState !== WebSocket.OPEN && socket.readyState !== WebSocket.CLOSING) {
  83. finish(0);
  84. }
  85. };
  86. function handleSignal() {
  87. beginLocalShutdown();
  88. }
  89. process.once("SIGINT", handleSignal);
  90. process.once("SIGTERM", handleSignal);
  91. try {
  92. socket = new WebSocket(endpoint.url);
  93. socket.binaryType = "arraybuffer";
  94. }
  95. catch {
  96. removeProcessListeners();
  97. writeDiagnostic(options.errorOutput, `connection error for ${endpoint.display}`);
  98. resolve(1);
  99. return;
  100. }
  101. startupTimer = setTimeout(() => {
  102. fail(`startup timeout after ${STARTUP_TIMEOUT_MS} ms for ${endpoint.display}`);
  103. }, STARTUP_TIMEOUT_MS);
  104. socket.addEventListener("open", () => {
  105. if (completed) {
  106. closeSocket();
  107. return;
  108. }
  109. if (startupTimer !== undefined) {
  110. clearTimeout(startupTimer);
  111. startupTimer = undefined;
  112. }
  113. lineReader = createInterface({
  114. input: options.input,
  115. crlfDelay: Infinity,
  116. terminal: false,
  117. });
  118. lineReader.on("line", (line) => {
  119. if (line.trim().length === 0) {
  120. return;
  121. }
  122. try {
  123. socket.send(line);
  124. }
  125. catch {
  126. fail(`transport error for ${endpoint.display}`);
  127. }
  128. });
  129. lineReader.once("close", beginLocalShutdown);
  130. });
  131. socket.addEventListener("message", (event) => {
  132. if (typeof event.data !== "string") {
  133. fail("protocol error: binary WebSocket frames are not supported", 1003);
  134. return;
  135. }
  136. options.output.write(`${event.data}\n`);
  137. });
  138. socket.addEventListener("error", () => {
  139. if (localShutdown) {
  140. return;
  141. }
  142. fail(`connection error for ${endpoint.display}`);
  143. });
  144. socket.addEventListener("close", () => {
  145. if (localShutdown) {
  146. finish(0);
  147. }
  148. else if (failure) {
  149. finish(1);
  150. }
  151. else {
  152. fail(`connection closed unexpectedly for ${endpoint.display}`);
  153. }
  154. });
  155. });
  156. }
  157. export async function main() {
  158. process.exitCode = await runBridge({
  159. arguments: process.argv.slice(2),
  160. environment: process.env,
  161. input: process.stdin,
  162. output: process.stdout,
  163. errorOutput: process.stderr,
  164. });
  165. }