Skip to content

Commit 42fd374

Browse files
committed
SageMath helper
1 parent 16ef0c7 commit 42fd374

File tree

1 file changed

+214
-0
lines changed

1 file changed

+214
-0
lines changed

utils/SageCell.js

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import WebSocket from "ws";
2+
import { randomUUID } from "crypto";
3+
4+
// ========================= //
5+
// = Copyright (c) NullDev = //
6+
// ========================= //
7+
8+
/* eslint-disable camelcase */
9+
10+
export default class SageCell {
11+
/**
12+
* @param {Object} options
13+
* @param {string} [options.baseUrl] Base URL of SageCell
14+
* @param {number} [options.timeoutMs] How long to wait for a single execution
15+
*/
16+
constructor(options = {}){
17+
this.baseUrl = options.baseUrl ?? "https://sagecell.sagemath.org";
18+
this.timeoutMs = options.timeoutMs ?? 30000;
19+
}
20+
21+
/**
22+
* High-level API: create kernel, run code, collect output, delete kernel.
23+
* @param {string} code
24+
* @returns {Promise<{ stdout: string, stderr: string, result: any }>}
25+
*/
26+
async askSage(code){
27+
let kernelId = null;
28+
try {
29+
const { wsUrl, kernelId: id } = await this.#startKernel();
30+
kernelId = id;
31+
32+
const result = await this.#runOnKernel(wsUrl, kernelId, code);
33+
return result;
34+
}
35+
finally {
36+
if (kernelId){
37+
try {
38+
await this.#deleteKernel(kernelId);
39+
}
40+
catch { /* noop */ }
41+
}
42+
}
43+
}
44+
45+
/**
46+
* Starts a new SageCell kernel.
47+
*
48+
* @returns {Promise<{ wsUrl: string, kernelId: string }>}
49+
*/
50+
async #startKernel(){
51+
const res = await fetch(
52+
`${this.baseUrl}/kernel?accepted_tos=true&timeout=0`,
53+
{ method: "POST" },
54+
);
55+
56+
if (!res.ok){
57+
throw new Error(`Failed to start kernel: HTTP ${res.status}`);
58+
}
59+
60+
const { ws_url, id } = await res.json();
61+
if (!ws_url || !id){
62+
throw new Error("Kernel start response missing ws_url or id");
63+
}
64+
65+
return { wsUrl: ws_url, kernelId: id };
66+
}
67+
68+
/**
69+
* Deletes a SageCell kernel.
70+
*
71+
* @param {string} kernelId
72+
* @returns {Promise<void>}
73+
*/
74+
async #deleteKernel(kernelId){
75+
const res = await fetch(`${this.baseUrl}/kernel/${kernelId}`, {
76+
method: "DELETE",
77+
});
78+
79+
if (!res.ok && res.status !== 404){
80+
throw new Error(`Failed to delete kernel: HTTP ${res.status}`);
81+
}
82+
}
83+
84+
/**
85+
* Creates an execute_request message.
86+
*
87+
* @param {string} code
88+
* @returns {Object}
89+
*/
90+
#makeExecuteRequest(code){
91+
const session = randomUUID();
92+
const msgId = randomUUID();
93+
return {
94+
header: {
95+
msg_id: msgId,
96+
username: "user",
97+
session,
98+
msg_type: "execute_request",
99+
version: "5.3",
100+
},
101+
parent_header: {},
102+
metadata: {},
103+
content: {
104+
code,
105+
silent: false,
106+
store_history: false,
107+
user_expressions: {},
108+
allow_stdin: false,
109+
stop_on_error: true,
110+
},
111+
channel: "shell",
112+
buffers: [],
113+
};
114+
}
115+
116+
/**
117+
* Connects WS, sends execute_request, waits until status: idle.
118+
*
119+
* @param {string} wsUrl
120+
* @param {string} kernelId
121+
* @param {string} code
122+
* @returns {Promise<{ stdout: string, stderr: string, result: any }>}
123+
*/
124+
async #runOnKernel(wsUrl, kernelId, code){
125+
const ws = new WebSocket(`${wsUrl}kernel/${kernelId}/channels`);
126+
127+
const execMsg = this.#makeExecuteRequest(code);
128+
129+
return new Promise((resolve, reject) => {
130+
let stdout = "";
131+
let stderr = "";
132+
let result = "";
133+
let done = false;
134+
// @ts-ignore
135+
// eslint-disable-next-line prefer-const
136+
let timer;
137+
138+
/**
139+
* Finishes the execution.
140+
*
141+
* @param {any} value
142+
* @param {boolean} isError
143+
*/
144+
const finish = (value, isError = false) => {
145+
if (done) return;
146+
done = true; // @ts-ignore
147+
clearTimeout(timer);
148+
try {
149+
ws.close();
150+
}
151+
catch {
152+
// ignore
153+
}
154+
isError ? reject(value) : resolve(value);
155+
};
156+
157+
timer = setTimeout(() => {
158+
finish(new Error(`SageCell execution timed out after ${this.timeoutMs}ms`), true);
159+
}, this.timeoutMs);
160+
161+
ws.on("open", () => {
162+
ws.send(JSON.stringify(execMsg));
163+
});
164+
165+
ws.on("message", data => {
166+
let msg;
167+
try {
168+
msg = JSON.parse(data.toString());
169+
}
170+
catch (e){
171+
const errMsg = e instanceof Error ? e.message : String(e);
172+
return finish(new Error("Failed to parse message from SageCell " + errMsg), true);
173+
}
174+
175+
const msgType = msg.msg_type || msg.header?.msg_type;
176+
177+
if (msgType === "stream" && msg.content?.text){
178+
if (msg.content.name === "stderr"){
179+
stderr += msg.content.text;
180+
}
181+
else {
182+
stdout += msg.content.text;
183+
}
184+
}
185+
186+
if (msgType === "execute_result"){
187+
result = msg.content?.data ?? null;
188+
}
189+
190+
if (msgType === "status" &&
191+
msg.content?.execution_state === "idle"){
192+
finish({ stdout, stderr, result });
193+
}
194+
195+
if (msgType === "error"){
196+
const errText = msg.content?.evalue || "SageCell error";
197+
finish(new Error(errText), true);
198+
}
199+
200+
return null;
201+
});
202+
203+
ws.on("error", err => {
204+
finish(err, true);
205+
});
206+
207+
ws.on("close", () => {
208+
if (!done){
209+
finish(new Error("WebSocket closed before completion"), true);
210+
}
211+
});
212+
});
213+
}
214+
}

0 commit comments

Comments
 (0)