wstp-node 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,206 @@
1
+ // =============================================================================
2
+ // wstp-backend/monitor_demo.js
3
+ //
4
+ // Shows three approaches to monitoring a running Wolfram computation in real
5
+ // time. The computation under observation is:
6
+ //
7
+ // Do[ i++; Pause[1], {i, 0, N-1} ] – increments i each second
8
+ //
9
+ // ── WHY a parallel subsession can't directly spy on the main kernel ─────────
10
+ //
11
+ // A WSTP subsession is a completely independent WolframKernel process; it has
12
+ // its own memory. You cannot query "i in kernel A" from "kernel B" — there is
13
+ // no shared address space.
14
+ //
15
+ // Additionally, while the main kernel is busy inside a Do-loop, its WSTP link
16
+ // is occupied: sending a second EvaluatePacket on the same link would be
17
+ // ignored / corrupt the link.
18
+ //
19
+ // ── The three working patterns ───────────────────────────────────────────────
20
+ //
21
+ // APPROACH 1 (JS-driven loop)
22
+ // Drive the loop from JS. Each `session.evaluate('i++')` returns the new
23
+ // value of i to JS immediately. JS controls the pacing and can read i
24
+ // between any two steps. No extra kernel or link needed.
25
+ //
26
+ // APPROACH 2 (side-channel link via LinkWrite)
27
+ // The kernel creates a named WSTP listener link, hands its name to JS, then
28
+ // runs the Do-loop. Inside the loop it calls LinkWrite[$mon, i] after
29
+ // each step. A WstpReader in JS receives every value the instant it is
30
+ // written — the two directions are fully independent:
31
+ // · main link carries EvaluatePacket[Do[...]] (busy, one-way)
32
+ // · monitor link carries integer values (streaming, read-only)
33
+ // This is true real-time monitoring of a running kernel.
34
+ //
35
+ // APPROACH 3 (shared temp file)
36
+ // Simplest, zero extra protocol: the loop writes i to /tmp/wstp_i.txt each
37
+ // tick; a Node.js setInterval reads the file every 500 ms. Works well
38
+ // when you don't want to write any C++ addon code.
39
+ // =============================================================================
40
+
41
+ 'use strict';
42
+
43
+ const path = require('path');
44
+ const fs = require('fs');
45
+
46
+ const { WstpSession, WstpReader } =
47
+ require(path.join(__dirname, '..', 'build', 'Release', 'wstp'));
48
+
49
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
50
+
51
+ function section(title) {
52
+ console.log('\n' + '═'.repeat(62));
53
+ console.log(' ' + title);
54
+ console.log('═'.repeat(62));
55
+ }
56
+
57
+ // ── pretty-print a typed ExprTree (integers just shown as number) ─────────────
58
+ function pp(node) {
59
+ if (!node) return String(node);
60
+ if (node.type === 'integer' || node.type === 'real') return String(node.value);
61
+ if (node.type === 'string') return `"${node.value}"`;
62
+ if (node.type === 'symbol') return node.value;
63
+ if (node.type === 'function')
64
+ return `${node.head}[${(node.args || []).map(pp).join(', ')}]`;
65
+ return JSON.stringify(node);
66
+ }
67
+
68
+ // =============================================================================
69
+ async function main() {
70
+ const STEPS = 8; // number of iterations (each sleeps 1 s in the kernel)
71
+
72
+ // =========================================================================
73
+ section('APPROACH 1 — JS-driven loop');
74
+ console.log(`
75
+ Instead of sending "Do[i++; Pause[1], {STEPS}]" as a single blocked call,
76
+ drive each step from JS. After every evaluate() call you receive the
77
+ current value of i and can print, log, or react to it.
78
+ `);
79
+ const s1 = new WstpSession();
80
+ await s1.evaluate('i = 0');
81
+
82
+ for (let step = 0; step < STEPS; step++) {
83
+ const result = await s1.evaluate('i++; i'); // increment and return
84
+ process.stdout.write(` step ${step + 1}/${STEPS} → i = ${pp(result)}\n`);
85
+ // The 1 s pause is simulated here on the JS side; replace with
86
+ // your real inter-step work if needed.
87
+ await sleep(1000);
88
+ }
89
+ s1.close();
90
+ console.log(' Session closed.\n');
91
+
92
+
93
+ // =========================================================================
94
+ section('APPROACH 2 — side-channel link (WstpReader + LinkWrite)');
95
+ console.log(`
96
+ The kernel creates a TCPIP WSTP listener link. A WstpReader in JS connects
97
+ to it. The Do-loop writes i to the monitor link after each step; JS
98
+ receives every integer the moment it is written, while the main evaluation
99
+ is still running.
100
+ `);
101
+ const s2 = new WstpSession();
102
+
103
+ // Step A: make the kernel create a listener link and tell us its name.
104
+ // $monLink is a LinkObject["port@host,port@host", id, n].
105
+ // [[1]] extracts the connection-name string directly.
106
+ console.log(' Creating monitor link inside kernel…');
107
+ await s2.evaluate('$monLink = LinkCreate[LinkProtocol -> "TCPIP"]');
108
+ const linkNameExpr = await s2.evaluate('$monLink[[1]]');
109
+ const linkName = linkNameExpr.value;
110
+ if (!linkName) throw new Error('Could not read link name: ' + JSON.stringify(linkNameExpr));
111
+ console.log(` Monitor link name: ${linkName}`);
112
+
113
+ // Step B: connect from JS — constructor is now non-blocking.
114
+ // WSActivate (the WSTP handshake) is deferred to the thread pool
115
+ // inside the first readNext() call, so it runs concurrently with the loop.
116
+ const reader = new WstpReader(linkName, 'TCPIP');
117
+ console.log(' WstpReader opened (handshake deferred to first read).');
118
+
119
+ // Step C: start the Do-loop WITHOUT awaiting — it runs on the thread pool.
120
+ // The kernel enters the loop and calls LinkWrite after each Pause[1].
121
+ // This is what allows WSActivate (called inside readNext) to complete:
122
+ // the kernel's WSTP runtime processes the pending connection from the
123
+ // reader as soon as it starts calling LinkWrite.
124
+ const loopDone = s2.evaluate(
125
+ `Do[ i++; LinkWrite[$monLink, i]; Pause[1], {i, 0, ${STEPS - 1}} ]; ` +
126
+ 'LinkClose[$monLink]; "loop-done"'
127
+ );
128
+
129
+ // Step D: read values as they stream in.
130
+ // The first readNext() also completes the WSActivate handshake on the
131
+ // thread pool — no blocking of the JS event loop at any point.
132
+ console.log(' Reading monitor stream:');
133
+ let received = 0;
134
+ while (received < STEPS) {
135
+ try {
136
+ const val = await reader.readNext();
137
+ process.stdout.write(` monitor → i = ${pp(val)}\n`);
138
+ received++;
139
+ } catch (err) {
140
+ // Link was closed by the kernel (normal end).
141
+ console.log(` Monitor link closed: ${err.message}`);
142
+ break;
143
+ }
144
+ }
145
+
146
+ // Step E: wait for the main evaluation to finish.
147
+ const loopResult = await loopDone;
148
+ console.log(` Main eval result: ${pp(loopResult)}`);
149
+ reader.close();
150
+ s2.close();
151
+
152
+
153
+ // =========================================================================
154
+ section('APPROACH 3 — shared temp file (no addon changes needed)');
155
+ console.log(`
156
+ The loop writes i to /tmp/wstp_i.txt after each step.
157
+ A Node.js setInterval polls the file every 500 ms.
158
+ Simplest option — no extra WSTP infrastructure.
159
+ `);
160
+ const POLL_FILE = '/tmp/wstp_monitor_i.txt';
161
+ const s3 = new WstpSession();
162
+
163
+ // Start the loop in background.
164
+ const loopDone3 = s3.evaluate(
165
+ `Do[ i++; Export["${POLL_FILE}", i, "Text"]; Pause[1], {i, 0, ${STEPS - 1}} ]; ` +
166
+ '"file-loop-done"'
167
+ );
168
+
169
+ // Poll from JS.
170
+ let lastSeen = null;
171
+ const poll = setInterval(() => {
172
+ try {
173
+ const txt = fs.readFileSync(POLL_FILE, 'utf8').trim();
174
+ if (txt !== lastSeen) {
175
+ lastSeen = txt;
176
+ process.stdout.write(` poll → i = ${txt}\n`);
177
+ }
178
+ } catch (_) { /* file not written yet */ }
179
+ }, 500);
180
+
181
+ await loopDone3;
182
+ clearInterval(poll);
183
+ // Final read to catch the last value.
184
+ try {
185
+ process.stdout.write(` final file value: ${fs.readFileSync(POLL_FILE, 'utf8').trim()}\n`);
186
+ } catch (_) {}
187
+ s3.close();
188
+
189
+
190
+ // =========================================================================
191
+ section('Summary');
192
+ console.log(`
193
+ ┌──────────────────────────┬────────────┬────────────┬───────────────────┐
194
+ │ Approach │ Real-time? │ New addon? │ Notes │
195
+ ├──────────────────────────┼────────────┼────────────┼───────────────────┤
196
+ │ 1. JS-driven loop │ yes │ no │ JS controls pace │
197
+ │ 2. WstpReader side chan. │ yes │ WstpReader │ true streaming │
198
+ │ 3. Temp file polling │ ~500 ms │ no │ simplest setup │
199
+ └──────────────────────────┴────────────┴────────────┴───────────────────┘
200
+ `);
201
+ }
202
+
203
+ main().catch(err => {
204
+ console.error('\nFatal error:', err);
205
+ process.exitCode = 1;
206
+ });
package/index.d.ts ADDED
@@ -0,0 +1,284 @@
1
+ // wstp-backend — TypeScript declarations
2
+
3
+ /**
4
+ * A Wolfram Language expression, converted from the WSTP wire format.
5
+ *
6
+ * Exactly one of the structural fields will be set depending on the
7
+ * expression type:
8
+ * - integer: `type === "integer"`, `value` is a JS number
9
+ * - real: `type === "real"`, `value` is a JS number
10
+ * - string: `type === "string"`, `value` is a JS string
11
+ * - symbol: `type === "symbol"`, `value` is the symbol name
12
+ * - function:`type === "function"`, `head` is the head name, `args` is the argument list
13
+ * - error: `type === undefined`, `error` is a message string (never resolved normally)
14
+ */
15
+ export interface WExpr {
16
+ type?: "integer" | "real" | "string" | "symbol" | "function";
17
+ value?: number | string; // set for integer / real / string / symbol
18
+ head?: string; // set for function
19
+ args?: WExpr[]; // set for function
20
+ error?: string; // set when addon encountered an internal error
21
+ }
22
+
23
+ /**
24
+ * Everything the kernel sends for one cell evaluation.
25
+ *
26
+ * The kernel sends packets in this order:
27
+ * InputNamePacket → cellIndex
28
+ * (computation)
29
+ * MessagePacket + TextPacket (0 or more pairs) → messages
30
+ * TextPacket (0 or more) → print
31
+ * OutputNamePacket → outputName
32
+ * ReturnPacket → result
33
+ */
34
+ export interface EvalResult {
35
+ /** The n in In[n]:= — monotonically increasing counter for each evaluation. */
36
+ cellIndex: number;
37
+ /** E.g. "Out[42]=" for a non-Null result, or "" when the result is Null. */
38
+ outputName: string;
39
+ /** The expression returned by the kernel (ReturnPacket payload). */
40
+ result: WExpr;
41
+ /** Lines written via Print[] or other output functions, in order. */
42
+ print: string[];
43
+ /**
44
+ * Kernel messages, one string per message.
45
+ * Format: "symbol::tag — human readable text"
46
+ * E.g. "Power::infy — Infinite expression 1/0 encountered."
47
+ */
48
+ messages: string[];
49
+ /** True when the evaluation was stopped by abort(). */
50
+ aborted: boolean;
51
+ }
52
+
53
+ /**
54
+ * Optional streaming callbacks for one evaluate() call.
55
+ *
56
+ * Both callbacks are invoked on the main thread as the kernel produces
57
+ * output — before the returned Promise resolves. Use them for real-time
58
+ * progress feedback during long computations.
59
+ */
60
+ export interface EvalOptions {
61
+ /** Called once per line written via Print[] (or similar output functions). */
62
+ onPrint?: (line: string) => void;
63
+ /** Called once per kernel message (e.g. "Power::infy: Infinite expression…"). */
64
+ onMessage?: (msg: string) => void;
65
+ /**
66
+ * Called when the kernel opens a Dialog[] subsession.
67
+ * @param level Nesting depth (1 for the outermost dialog).
68
+ */
69
+ onDialogBegin?: (level: number) => void;
70
+ /**
71
+ * Called once per Print[] line produced inside the dialog subsession.
72
+ * @param line Output line text.
73
+ */
74
+ onDialogPrint?: (line: string) => void;
75
+ /**
76
+ * Called when the dialog subsession closes (Return[] or kernel exit).
77
+ * @param level The nesting depth that just closed.
78
+ */
79
+ onDialogEnd?: (level: number) => void;
80
+ }
81
+
82
+ /**
83
+ * A session wrapping one WolframKernel process connected over WSTP.
84
+ *
85
+ * Multiple evaluate() calls are automatically serialised through an
86
+ * internal queue — it is safe to fire them without awaiting. The kernel
87
+ * link is never corrupted even under concurrent callers.
88
+ */
89
+ export class WstpSession {
90
+ /**
91
+ * Launch a new WolframKernel process and connect to it over WSTP.
92
+ * @param kernelPath Full path to WolframKernel binary.
93
+ * Defaults to the standard macOS install location.
94
+ * @throws if the kernel cannot be launched or the link fails to activate.
95
+ */
96
+ constructor(kernelPath?: string);
97
+
98
+ /**
99
+ * Evaluate a Wolfram Language expression string and return the full result.
100
+ *
101
+ * The kernel parses the string with ToExpression, evaluates it, and
102
+ * returns an EvalResult capturing the return value, all Print[] output,
103
+ * and all kernel messages that were emitted during evaluation.
104
+ *
105
+ * Multiple calls are serialised automatically — you may fire them
106
+ * concurrently without awaiting; each one will start after the previous
107
+ * one finishes.
108
+ *
109
+ * @param expr Wolfram Language expression as a string.
110
+ * @param opts Optional streaming callbacks (`onPrint`, `onMessage`).
111
+ * @returns Promise that resolves with the EvalResult when the kernel responds.
112
+ */
113
+ evaluate(expr: string, opts?: EvalOptions): Promise<EvalResult>;
114
+
115
+ /**
116
+ * Exit the currently-open Dialog[] subsession.
117
+ *
118
+ * Sends `Return[retVal]` as `EnterTextPacket` — the interactive-context
119
+ * packet that the kernel recognises as "exit the dialog". This is
120
+ * different from `dialogEval('Return[]')` which uses `EvaluatePacket`
121
+ * and leaves `Return[]` unevaluated (no enclosing Do/Block to return from).
122
+ *
123
+ * Resolves with `null` once `EndDialogPacket` is received.
124
+ * Rejects immediately if `isDialogOpen` is false.
125
+ *
126
+ * @param retVal Optional Wolfram Language string to pass as the return
127
+ * value of `Dialog[]`, e.g. `'42'` or `'myVar'`.
128
+ */
129
+ exitDialog(retVal?: string): Promise<null>;
130
+
131
+ /**
132
+ * Evaluate an expression inside the currently-open Dialog[] subsession.
133
+ *
134
+ * Queues `expr` for evaluation by the kernel's dialog REPL. Resolves with
135
+ * the WExpr result once the dialog loop has processed it. Rejects if
136
+ * `isDialogOpen` is false at call time (i.e. no dialog is currently open).
137
+ *
138
+ * @param expr Wolfram Language expression string.
139
+ * @returns Promise that resolves with the WExpr result.
140
+ */
141
+ dialogEval(expr: string): Promise<WExpr>;
142
+
143
+ /**
144
+ * Send WSInterruptMessage to the kernel (best-effort).
145
+ *
146
+ * Whether this has any effect depends on whether a Wolfram-side interrupt
147
+ * handler has been installed, e.g.:
148
+ * ```
149
+ * Internal`AddHandler["Interrupt", Function[Null, Dialog[]]]
150
+ * ```
151
+ * Without such a handler this is a no-op. For a guaranteed way to enter
152
+ * a dialog, call `Dialog[]` directly from Wolfram code.
153
+ *
154
+ * @returns true if the message was posted to the link successfully.
155
+ */
156
+ interrupt(): boolean;
157
+
158
+ /** True while a Dialog[] subsession is open on this link. */
159
+ readonly isDialogOpen: boolean;
160
+
161
+ /**
162
+ * Interrupt the currently running evaluate() call.
163
+ *
164
+ * Posts WSAbortMessage to the kernel. The kernel stops its current
165
+ * computation and the evaluate() Promise resolves with `aborted: true`
166
+ * and `result: { type: "symbol", value: "$Aborted" }`.
167
+ *
168
+ * The kernel remains alive and can accept further evaluations.
169
+ *
170
+ * @returns true if the abort message was posted successfully.
171
+ */
172
+ abort(): boolean;
173
+
174
+ /**
175
+ * Evaluate an expression, returning just the result expression
176
+ * (not a full EvalResult).
177
+ *
178
+ * `sub()` is always prioritised over pending `evaluate()` calls: it runs
179
+ * before any already-queued evaluations. If the session is currently busy,
180
+ * `sub()` waits for the in-flight evaluation to finish, then runs next
181
+ * (ahead of any other queued `evaluate()` calls). If idle, it starts
182
+ * immediately.
183
+ *
184
+ * Multiple `sub()` calls are queued FIFO among themselves and all run
185
+ * before the next `evaluate()` call in the queue.
186
+ *
187
+ * @param expr Wolfram Language expression string to evaluate.
188
+ * @returns Promise that resolves with the WExpr result.
189
+ */
190
+ sub(expr: string): Promise<WExpr>;
191
+
192
+ /**
193
+ * Launch an independent child kernel as a new WstpSession.
194
+ *
195
+ * The child has completely isolated state (variables, definitions, memory).
196
+ * It must be closed independently with child.close().
197
+ *
198
+ * @param kernelPath Optional path override; defaults to the parent's path.
199
+ */
200
+ createSubsession(kernelPath?: string): WstpSession;
201
+
202
+ /**
203
+ * Send Quit[] to the kernel, close the link, and release all resources.
204
+ * Subsequent calls to evaluate() will reject immediately.
205
+ */
206
+ close(): void;
207
+
208
+ /** True while the link is open and the kernel is running. */
209
+ readonly isOpen: boolean;
210
+ }
211
+
212
+ /**
213
+ * A reader that connects to a named WSTP link created by the kernel
214
+ * (via `LinkCreate`) and receives expressions pushed by the kernel
215
+ * (via `LinkWrite`).
216
+ *
217
+ * Use this for real-time monitoring: the kernel pushes variable snapshots
218
+ * while the main link is blocked on a long evaluation.
219
+ *
220
+ * @example
221
+ * // Wolfram side:
222
+ * // $mon = LinkCreate[LinkProtocol -> "TCPIP"];
223
+ * // linkName = $mon[[1]]; (* extract string from LinkObject *)
224
+ * // Do[LinkWrite[$mon, i]; Pause[1], {i, 1, 10}];
225
+ * // LinkClose[$mon];
226
+ *
227
+ * // JS side:
228
+ * const reader = new WstpReader(linkName);
229
+ * while (reader.isOpen) {
230
+ * try {
231
+ * const v = await reader.readNext();
232
+ * console.log('monitor:', v);
233
+ * } catch { break; }
234
+ * }
235
+ */
236
+ export class WstpReader {
237
+ /**
238
+ * Connect to a named WSTP link that is already listening.
239
+ *
240
+ * @param linkName The name returned by Wolfram's `$link[[1]]` or `LinkName[$link]`.
241
+ * For TCPIP links this looks like "port@host,0@host".
242
+ * @param protocol Link protocol, default "TCPIP".
243
+ * @throws if the connection cannot be established.
244
+ */
245
+ constructor(linkName: string, protocol?: string);
246
+
247
+ /**
248
+ * Wait for the next expression written by the kernel via LinkWrite.
249
+ *
250
+ * Blocks on Node's thread pool until an expression arrives.
251
+ * When the kernel closes the link (LinkClose[$link]), this rejects.
252
+ *
253
+ * @returns Promise that resolves with the next WExpr.
254
+ */
255
+ readNext(): Promise<WExpr>;
256
+
257
+ /** Close the link and release resources. */
258
+ close(): void;
259
+
260
+ /** True while the link is open. */
261
+ readonly isOpen: boolean;
262
+ }
263
+
264
+ /**
265
+ * Register a callback that receives internal C++ diagnostic messages.
266
+ *
267
+ * Messages include WSTP packet traces (`[Eval] pkt=N`), TSFN dispatch
268
+ * timestamps (`[TSFN][onPrint] dispatch +Nms`), `WstpReader` spin-wait
269
+ * traces, and kernel lifecycle events (`[WarmUp]`, `[Session]`).
270
+ *
271
+ * The callback fires on the JS main thread, so it can safely write to
272
+ * `process.stderr` or update UI. It does **not** prevent the Node.js
273
+ * process from exiting normally.
274
+ *
275
+ * Alternatively, set `DEBUG_WSTP=1` in the environment to write the same
276
+ * messages directly to `stderr` from C++ (no JS handler needed):
277
+ * ```
278
+ * DEBUG_WSTP=1 node myscript.js 2>diag.txt
279
+ * ```
280
+ *
281
+ * @param fn Callback receiving each diagnostic message string,
282
+ * or `null` / `undefined` to clear a previously-set handler.
283
+ */
284
+ export function setDiagHandler(fn: ((msg: string) => void) | null | undefined): void;
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "wstp-node",
3
+ "version": "0.3.0",
4
+ "description": "Native Node.js addon for Wolfram/Mathematica WSTP — kernel sessions with evaluation queue, streaming Print/messages, Dialog subsessions, and side-channel WstpReader",
5
+ "main": "build/Release/wstp.node",
6
+ "types": "index.d.ts",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/vanbaalon/mathematica-wstp-node.git"
10
+ },
11
+ "keywords": [
12
+ "wolfram",
13
+ "mathematica",
14
+ "wstp",
15
+ "native-addon",
16
+ "kernel",
17
+ "wolframscript"
18
+ ],
19
+ "author": "vanbaalon",
20
+ "license": "MIT",
21
+ "os": [
22
+ "darwin",
23
+ "linux"
24
+ ],
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "files": [
29
+ "src/addon.cc",
30
+ "build.sh",
31
+ "binding.gyp",
32
+ "index.d.ts",
33
+ "examples/",
34
+ "test.js",
35
+ "test_interrupt_dialog.js"
36
+ ],
37
+ "scripts": {
38
+ "install": "bash build.sh",
39
+ "build": "bash build.sh",
40
+ "build:debug": "bash build.sh debug",
41
+ "clean": "bash build.sh clean",
42
+ "test": "node test.js",
43
+ "demo": "node examples/demo.js"
44
+ },
45
+ "dependencies": {
46
+ "node-addon-api": "^8.0.0"
47
+ },
48
+ "devDependencies": {
49
+ "node-gyp": "^10.0.0"
50
+ }
51
+ }