@smartspectra/node-sdk 3.2.0-rc.6

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,404 @@
1
+ // js/main/index.js
2
+ // Copyright (C) 2026 Presage Technologies, Inc.
3
+ //
4
+ // SPDX-License-Identifier: LicenseRef-Proprietary
5
+ //
6
+ // Main-process IPC glue for `@smartspectra/node-sdk/renderer`. The renderer
7
+ // owns a `MediaStream` and pumps decoded RGBA frames over a MessagePort
8
+ // created by the preload bridge; this module instantiates a
9
+ // `SmartSpectraSDK`, dispatches frames into `sendFrame()`, and forwards
10
+ // SDK callbacks back through the same port.
11
+
12
+ 'use strict';
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const { ipcMain, app } = require('electron');
17
+
18
+ const {
19
+ SmartSpectraSDK,
20
+ ProcessingStatus,
21
+ } = require('../smartspectra');
22
+
23
+ const ATTACH_CHANNEL = 'smartspectra:attach';
24
+
25
+ let preconfigured = false;
26
+
27
+ // Process-wide barrier serializing native session teardown against the next
28
+ // session start. SDK state is process-global, so two SmartSpectra sessions
29
+ // must never run concurrently. SmartSpectraSDK.destroy() defers native
30
+ // teardown while a stopAsync() drains on a koffi worker thread, so a
31
+ // configure/reload arriving mid-drain could otherwise stand up a new session
32
+ // before the old one finishes winding down — including across a renderer
33
+ // reload, where the replacement is a fresh connection that can't see the prior
34
+ // connection's pending teardown. Every session start awaits this barrier;
35
+ // every teardown folds itself in. (Process-scoped, not per-connection, for the
36
+ // reload case.)
37
+ let sessionTeardown = Promise.resolve();
38
+
39
+ // Tear `sdk` down (no-op if null) and fold its possibly-deferred native
40
+ // teardown into the barrier so the next start waits for it. Never rejects.
41
+ function retireSession(sdk) {
42
+ if (!sdk) return;
43
+ let done;
44
+ try { done = sdk.destroy(); }
45
+ catch (e) { done = Promise.reject(e); }
46
+ sessionTeardown = Promise.allSettled([sessionTeardown, Promise.resolve(done)])
47
+ .then(() => {});
48
+ }
49
+
50
+ // Per-host base directory the SDK initializes against. Namespaced under
51
+ // `app.getName()` to avoid collisions when multiple Electron hosts share
52
+ // the OS-level base (e.g. unpackaged `electron .` dev runs on macOS).
53
+ function defaultSdkDirectory() {
54
+ return path.join(app.getPath('cache'), app.getName(), 'SmartSpectraSDK');
55
+ }
56
+
57
+ // Idempotent one-time SDK initialization. Not exported.
58
+ function ensurePreconfigured() {
59
+ if (preconfigured) return;
60
+ const { preconfigure: capiPreconfigure } = require('../ffi');
61
+ const sdkDirectory = defaultSdkDirectory();
62
+ // Pre-create the tree with restrictive permissions; Electron's
63
+ // app.getPath('cache') root isn't always present on first launch.
64
+ try {
65
+ fs.mkdirSync(sdkDirectory, { recursive: true, mode: 0o700 });
66
+ } catch (err) {
67
+ throw new Error(
68
+ `SmartSpectra: failed to prepare SDK directory ${sdkDirectory}: ` +
69
+ `${err.message || err}. Some metrics (arterial pressure, EDA, ` +
70
+ 'micromotion) need this to initialize; other metrics will still ' +
71
+ 'work.');
72
+ }
73
+ if (process.env.SMARTSPECTRA_DIAGNOSTICS === '1') {
74
+ console.log(`[smartspectra/main] preconfigure invoked`);
75
+ }
76
+ capiPreconfigure(sdkDirectory);
77
+ preconfigured = true;
78
+ }
79
+
80
+ const defaultLogger = (level, msg) => {
81
+ if (level === 'error') console.error(`[smartspectra/main] ${msg}`);
82
+ else if (level === 'warn') console.warn(`[smartspectra/main] ${msg}`);
83
+ else console.log(`[smartspectra/main] ${msg}`);
84
+ };
85
+
86
+ // The main process treats every IPC payload as untrusted: a BrowserWindow that
87
+ // later loads remote content (or any frame inside the renderer) could post
88
+ // messages on the bridge. Clamp the fields we forward into native code so a
89
+ // hostile shape can't cause a multi-GB allocation in koffi or trip the SDK's
90
+ // own sanity checks with an out-of-range value.
91
+ const MAX_API_KEY_BYTES = 1024;
92
+ const MAX_REQUESTED_METRICS = 128; // current MetricType enum has ~30 values
93
+ const MAX_INSIGHT_TEXT_BYTES = 8192;
94
+ const MAX_FRAME_DIM = 8192; // 8K resolution headroom
95
+ const MAX_FRAME_BUFFER_BYTES = 256 * 1024 * 1024; // 256 MiB — covers 8K RGBA + slack
96
+ const SAFE_INT32 = 2147483647;
97
+
98
+ function sanitizeOptions(raw) {
99
+ const opts = (raw && typeof raw === 'object') ? raw : {};
100
+ const out = {};
101
+
102
+ if (typeof opts.apiKey === 'string') {
103
+ out.apiKey = opts.apiKey.length > MAX_API_KEY_BYTES
104
+ ? opts.apiKey.slice(0, MAX_API_KEY_BYTES)
105
+ : opts.apiKey;
106
+ }
107
+
108
+ if (Array.isArray(opts.requestedMetrics)) {
109
+ const src = opts.requestedMetrics.length > MAX_REQUESTED_METRICS
110
+ ? opts.requestedMetrics.slice(0, MAX_REQUESTED_METRICS)
111
+ : opts.requestedMetrics;
112
+ const cleaned = [];
113
+ for (const v of src) {
114
+ if (typeof v === 'number' && Number.isFinite(v) &&
115
+ v >= -SAFE_INT32 - 1 && v <= SAFE_INT32) {
116
+ cleaned.push(v | 0);
117
+ }
118
+ }
119
+ if (cleaned.length > 0) out.requestedMetrics = cleaned;
120
+ }
121
+
122
+ out.enableAccumulatedOutput = !!opts.enableAccumulatedOutput;
123
+ return out;
124
+ }
125
+
126
+ function sanitizeInsightText(raw) {
127
+ if (typeof raw !== 'string') {
128
+ throw new TypeError('requestInsight: text must be a string');
129
+ }
130
+ if (raw.length > MAX_INSIGHT_TEXT_BYTES) {
131
+ throw new RangeError(
132
+ `requestInsight: text length ${raw.length} exceeds ${MAX_INSIGHT_TEXT_BYTES}`);
133
+ }
134
+ return raw;
135
+ }
136
+
137
+ /**
138
+ * Wires the per-window IPC channel that backs `@smartspectra/node-sdk/renderer`.
139
+ *
140
+ * Call once per `BrowserWindow`. The binding tracks the window via
141
+ * `'closed'` and releases its SDK + port automatically.
142
+ *
143
+ * @param {import('electron').BrowserWindow} window
144
+ * @param {{ logger?: (level: 'info' | 'warn' | 'error', msg: string) => void }} [options]
145
+ */
146
+ function bindSmartSpectraIpc(window, options = {}) {
147
+ const log = options.logger || defaultLogger;
148
+ const wc = window.webContents;
149
+ if (!wc) throw new TypeError('bindSmartSpectraIpc: window has no webContents');
150
+
151
+ // Best-effort: preconfigure failures don't block the bind — basic metrics
152
+ // still work.
153
+ try {
154
+ ensurePreconfigured();
155
+ } catch (err) {
156
+ log('warn', `preconfigure failed: ${err.message || err}`);
157
+ }
158
+
159
+ const onAttach = (event, _payload) => {
160
+ if (event.sender !== wc) return; // multi-window safety
161
+ const port = event.ports && event.ports[0];
162
+ if (!port) {
163
+ log('warn', 'attach message received without a MessagePort');
164
+ return;
165
+ }
166
+ handleConnection(port, log);
167
+ };
168
+
169
+ ipcMain.on(ATTACH_CHANNEL, onAttach);
170
+ window.once('closed', () => {
171
+ ipcMain.removeListener(ATTACH_CHANNEL, onAttach);
172
+ });
173
+ }
174
+
175
+ // Handle a single renderer connection. Owns one SDK instance for the port's
176
+ // lifetime; the port stays open until the renderer closes it or the window
177
+ // navigates / unloads.
178
+ function handleConnection(port, log) {
179
+ let sdk = null;
180
+ let framesReceived = 0;
181
+ let framesDropped = 0;
182
+ const diagnostics = process.env.SMARTSPECTRA_DIAGNOSTICS === '1';
183
+ const FRAME_LOG_INTERVAL = 30; // ~once per second at 30 fps
184
+
185
+ const safePost = (msg) => {
186
+ try { port.postMessage(msg); }
187
+ catch (e) { log('warn', `port.postMessage failed: ${e.message}`); }
188
+ };
189
+
190
+ // Buffer-bearing messages go through structured clone, not transfer:
191
+ // Electron's `MessagePortMain.postMessage` rejects ArrayBuffers in the
192
+ // transfer list with "Port at index 0 is not a valid port". The clone
193
+ // cost on the protobuf payload (~few KB per packet) is negligible.
194
+ const installCallbacks = () => {
195
+ sdk.on('processingStatus', (status) =>
196
+ safePost({ kind: 'processingStatus', status }));
197
+ sdk.on('validationStatus', (code, ts, hint) =>
198
+ safePost({ kind: 'validationStatus', code, timestampUs: ts, hint }));
199
+ sdk.on('metrics', (buf, ts) =>
200
+ safePost({ kind: 'metrics', buffer: toArrayBufferPayload(buf), timestampUs: ts }));
201
+ sdk.on('accumulatedMetrics', (buf, ts) =>
202
+ safePost({ kind: 'accumulatedMetrics', buffer: toArrayBufferPayload(buf), timestampUs: ts }));
203
+ sdk.on('insight', (buf, requestId) =>
204
+ safePost({ kind: 'insight', buffer: toArrayBufferPayload(buf), requestId }));
205
+ sdk.on('frameSentThrough', (sent, ts) =>
206
+ safePost({ kind: 'frameSentThrough', sent, timestampUs: ts }));
207
+ sdk.on('error', (code, message, retryable) =>
208
+ safePost({ kind: 'error', code, message, retryable }));
209
+ };
210
+
211
+ // start/stop/reset acks carry no payload; requestInsight carries
212
+ // `{ requestId }`.
213
+ const ackOk = (replyId, payload) =>
214
+ safePost({ kind: 'ack', replyId, ok: true, payload: payload || null });
215
+ const ackFail = (replyId, e) => safePost({
216
+ kind: 'ack',
217
+ replyId,
218
+ ok: false,
219
+ error: {
220
+ code: typeof e.code === 'number' ? e.code : 0,
221
+ message: e.message || String(e),
222
+ retryable: !!e.retryable,
223
+ },
224
+ });
225
+
226
+ port.on('message', (event) => {
227
+ const data = event && event.data;
228
+ if (diagnostics && (!data || typeof data.kind !== 'string')) {
229
+ log('warn', `dropped malformed port message: ` +
230
+ `data=${data === undefined ? 'undefined' : data === null ? 'null' : typeof data} ` +
231
+ `keys=${data && typeof data === 'object' ? Object.keys(data).join(',') : '-'}`);
232
+ }
233
+ if (!data || typeof data.kind !== 'string') return;
234
+ if (diagnostics && data.kind !== 'frame') {
235
+ // Frame messages have their own throttled counter below.
236
+ log('info', `<- ${data.kind}` +
237
+ (data.replyId !== undefined ? ` (replyId=${data.replyId})` : ''));
238
+ }
239
+ try {
240
+ switch (data.kind) {
241
+ case 'configure':
242
+ // Retire the prior session (folding its teardown into the
243
+ // barrier) before standing up the replacement. The new SDK
244
+ // is lazy — no native session exists until start(), which
245
+ // awaits the barrier — so the two never overlap natively.
246
+ retireSession(sdk);
247
+ sdk = new SmartSpectraSDK(sanitizeOptions(data.options));
248
+ installCallbacks();
249
+ return;
250
+
251
+ case 'start': {
252
+ if (!sdk) throw new Error('start before configure');
253
+ const target = sdk;
254
+ const { replyId } = data;
255
+ const frameTransform = data.frameTransform | 0;
256
+ // Defer the native session creation until any prior
257
+ // session's teardown has fully drained (see sessionTeardown).
258
+ // Frames arriving in the meantime are dropped (no started
259
+ // session yet) — the same as the normal pre-start window.
260
+ sessionTeardown.then(() => {
261
+ if (sdk !== target) {
262
+ // A reconfigure/teardown superseded this start while
263
+ // we waited; starting the detached session would
264
+ // re-introduce the overlap we're guarding against.
265
+ ackFail(replyId, new Error('session replaced before start completed'));
266
+ return;
267
+ }
268
+ target.useCustomInput(frameTransform);
269
+ target.start();
270
+ ackOk(replyId);
271
+ }).catch((e) => ackFail(replyId, e));
272
+ return;
273
+ }
274
+
275
+ case 'stop':
276
+ // sdk.stop() blocks until the processing pipeline drains
277
+ // (can take multiple seconds). Run asynchronously so this
278
+ // IPC handler returns immediately and the event loop
279
+ // stays free.
280
+ if (sdk) {
281
+ sdk.stopAsync()
282
+ .then(() => ackOk(data.replyId))
283
+ .catch((e) => ackFail(data.replyId, e));
284
+ } else {
285
+ ackOk(data.replyId);
286
+ }
287
+ return;
288
+
289
+ case 'reset':
290
+ if (sdk) sdk.reset();
291
+ return ackOk(data.replyId);
292
+
293
+ case 'frame': {
294
+ if (!sdk) return;
295
+ const width = data.width | 0;
296
+ const height = data.height | 0;
297
+ if (width <= 0 || width > MAX_FRAME_DIM ||
298
+ height <= 0 || height > MAX_FRAME_DIM ||
299
+ !data.buffer ||
300
+ typeof data.buffer.byteLength !== 'number' ||
301
+ data.buffer.byteLength > MAX_FRAME_BUFFER_BYTES) {
302
+ framesDropped++;
303
+ if (framesDropped === 1 || framesDropped % FRAME_LOG_INTERVAL === 0) {
304
+ log('warn',
305
+ `dropped frame with out-of-range shape (${framesDropped} total)`);
306
+ }
307
+ return;
308
+ }
309
+ if (diagnostics && framesReceived === 0) {
310
+ log('info', `received first frame ` +
311
+ `(${width}x${height} fmt=${data.pixelFormat} ` +
312
+ `bytes=${data.buffer.byteLength} ` +
313
+ `ts=${data.timestampUs})`);
314
+ }
315
+ // Frames carry no replyId, so a throw would otherwise be
316
+ // reported to the renderer as an 'error' event on every
317
+ // offending frame (30 fps). Transient ordering failures
318
+ // (non-monotonic timestamp, gap, post-stop) are expected
319
+ // during start/stop churn — drop the frame and log at most
320
+ // once per FRAME_LOG_INTERVAL instead of spamming 'error'.
321
+ try {
322
+ sdk.sendFrame(
323
+ Buffer.from(data.buffer),
324
+ width,
325
+ height,
326
+ data.stride | 0,
327
+ data.pixelFormat | 0,
328
+ data.timestampUs,
329
+ );
330
+ } catch (e) {
331
+ framesDropped++;
332
+ if (framesDropped === 1 || framesDropped % FRAME_LOG_INTERVAL === 0) {
333
+ log('warn', `dropped frame (${framesDropped} total): ` +
334
+ `${e.message || e}`);
335
+ }
336
+ return;
337
+ }
338
+ framesReceived++;
339
+ if (diagnostics && framesReceived % FRAME_LOG_INTERVAL === 0) {
340
+ log('info', `received ${framesReceived} frames ` +
341
+ `(${data.width}x${data.height} fmt=${data.pixelFormat})`);
342
+ }
343
+ return;
344
+ }
345
+
346
+ case 'requestInsight': {
347
+ if (!sdk) throw new Error('requestInsight before configure');
348
+ const id = sdk.requestInsight(sanitizeInsightText(data.text));
349
+ return ackOk(data.replyId, { requestId: id });
350
+ }
351
+
352
+ case 'destroy':
353
+ retireSession(sdk);
354
+ sdk = null;
355
+ try { port.close(); } catch { /* already closed */ }
356
+ return;
357
+
358
+ default:
359
+ log('warn', `unknown message kind: ${data.kind}`);
360
+ }
361
+ } catch (e) {
362
+ if (typeof data.replyId === 'number') {
363
+ ackFail(data.replyId, e);
364
+ } else {
365
+ safePost({
366
+ kind: 'error',
367
+ code: typeof e.code === 'number' ? e.code : 0,
368
+ message: e.message || String(e),
369
+ retryable: !!e.retryable,
370
+ });
371
+ }
372
+ }
373
+ });
374
+
375
+ port.on('close', () => {
376
+ // Fold teardown into the barrier so a reload's replacement session
377
+ // (a fresh connection) waits for this one to finish winding down.
378
+ retireSession(sdk);
379
+ sdk = null;
380
+ });
381
+
382
+ port.start();
383
+ }
384
+
385
+ // Extract an ArrayBuffer from a Node Buffer for the structured-clone message
386
+ // payload (the port clones, it does not transfer — see installCallbacks).
387
+ // Returns the underlying ArrayBuffer directly when the Buffer spans it
388
+ // exactly; otherwise tightens a sliced view into its own ArrayBuffer.
389
+ function toArrayBufferPayload(buf) {
390
+ if (buf.byteOffset === 0 && buf.byteLength === buf.buffer.byteLength) {
391
+ return buf.buffer;
392
+ }
393
+ // Sliced Buffer view — make a tight ArrayBuffer.
394
+ const ab = new ArrayBuffer(buf.byteLength);
395
+ new Uint8Array(ab).set(buf);
396
+ return ab;
397
+ }
398
+
399
+ module.exports = {
400
+ bindSmartSpectraIpc,
401
+ // Re-exported for consumers driving the low-level surface directly.
402
+ SmartSpectraSDK,
403
+ ProcessingStatus,
404
+ };