@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.
- package/LICENSE +13 -0
- package/README.md +409 -0
- package/js/constants.js +125 -0
- package/js/ffi.js +494 -0
- package/js/index.d.ts +282 -0
- package/js/index.js +60 -0
- package/js/main/index.d.ts +32 -0
- package/js/main/index.js +404 -0
- package/js/messages/generated.d.ts +2083 -0
- package/js/messages/generated.js +5810 -0
- package/js/messages/index.d.ts +27 -0
- package/js/messages/index.js +67 -0
- package/js/preload/index.js +116 -0
- package/js/renderer/index.d.ts +154 -0
- package/js/renderer/index.js +670 -0
- package/js/resolve-native.js +113 -0
- package/js/smartspectra.js +293 -0
- package/package.json +81 -0
package/js/ffi.js
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
// ffi.js
|
|
2
|
+
// Copyright (C) 2026 Presage Technologies, Inc.
|
|
3
|
+
//
|
|
4
|
+
// SPDX-License-Identifier: LicenseRef-Proprietary
|
|
5
|
+
//
|
|
6
|
+
// Pure-FFI bindings for smartspectra/capi. Type declarations mirror
|
|
7
|
+
// smartspectra/capi/smartspectra_capi.h one-to-one — field order,
|
|
8
|
+
// append-only struct evolution, and callback signatures are load-bearing.
|
|
9
|
+
// The header's `_Static_assert` layout locks are the source of truth.
|
|
10
|
+
//
|
|
11
|
+
// Exposes a low-level `Session` class. The public, event-oriented
|
|
12
|
+
// `SmartSpectraSDK` lives in smartspectra.js and wraps Session.
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const koffi = require('koffi');
|
|
17
|
+
const { resolveNativeLibrary } = require('./resolve-native');
|
|
18
|
+
|
|
19
|
+
const lib = koffi.load(resolveNativeLibrary());
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Type declarations — mirror smartspectra_capi.h
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
// Opaque session token. C side: `struct smartspectra_session_s*`.
|
|
26
|
+
koffi.opaque('smartspectra_session_t');
|
|
27
|
+
|
|
28
|
+
// smartspectra_error_t — keep field order identical to the C struct; koffi
|
|
29
|
+
// computes offsets by declaration order.
|
|
30
|
+
koffi.struct('smartspectra_error_t', {
|
|
31
|
+
code: 'int',
|
|
32
|
+
message: koffi.array('char', 512, 'String'), // NUL-terminated, decoded to JS string
|
|
33
|
+
retryable: 'int',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// smartspectra_config_t — append-only ABI.
|
|
37
|
+
koffi.struct('smartspectra_config_t', {
|
|
38
|
+
api_key: 'const char *',
|
|
39
|
+
requested_metrics: 'const int32_t *',
|
|
40
|
+
requested_metrics_len: 'int',
|
|
41
|
+
enable_accumulated_output: 'int',
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Callback prototypes. koffi.register() returns thread-safe thunks: a call
|
|
45
|
+
// originating on an SDK worker thread is marshalled to the V8 event loop
|
|
46
|
+
// via libuv before invoking the JS function.
|
|
47
|
+
koffi.proto('void smartspectra_on_status(uintptr_t handle, int status)');
|
|
48
|
+
koffi.proto('void smartspectra_on_validation(uintptr_t handle, int code, ' +
|
|
49
|
+
'const char *hint, int64_t timestamp_us)');
|
|
50
|
+
koffi.proto('void smartspectra_on_metrics(uintptr_t handle, ' +
|
|
51
|
+
'const uint8_t *proto_bytes, int proto_len, int64_t timestamp_us)');
|
|
52
|
+
koffi.proto('void smartspectra_on_error(uintptr_t handle, int code, ' +
|
|
53
|
+
'const char *message, int retryable)');
|
|
54
|
+
koffi.proto('void smartspectra_on_frame(uintptr_t handle, int sent, int64_t timestamp_us)');
|
|
55
|
+
koffi.proto('void smartspectra_on_accumulated_metrics(uintptr_t handle, ' +
|
|
56
|
+
'const uint8_t *proto_bytes, int proto_len, int64_t timestamp_us)');
|
|
57
|
+
koffi.proto('void smartspectra_on_insight(uintptr_t handle, ' +
|
|
58
|
+
'const uint8_t *proto_bytes, int proto_len, int32_t request_id)');
|
|
59
|
+
koffi.proto('void smartspectra_on_video_output(uintptr_t handle, ' +
|
|
60
|
+
'const uint8_t *data, int width, int height, int stride_bytes, ' +
|
|
61
|
+
'int pixel_format, int64_t timestamp_us)');
|
|
62
|
+
|
|
63
|
+
// smartspectra_callbacks_t — slots are append-only.
|
|
64
|
+
koffi.struct('smartspectra_callbacks_t', {
|
|
65
|
+
on_status: 'smartspectra_on_status *',
|
|
66
|
+
on_validation: 'smartspectra_on_validation *',
|
|
67
|
+
on_metrics: 'smartspectra_on_metrics *',
|
|
68
|
+
on_error: 'smartspectra_on_error *',
|
|
69
|
+
on_frame: 'smartspectra_on_frame *',
|
|
70
|
+
on_accumulated_metrics: 'smartspectra_on_accumulated_metrics *',
|
|
71
|
+
on_insight: 'smartspectra_on_insight *',
|
|
72
|
+
on_video_output: 'smartspectra_on_video_output *',
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Entry-point bindings. `_Out_` marks pure-output struct args so koffi
|
|
77
|
+
// allocates the backing storage and decodes the result back into the
|
|
78
|
+
// caller-supplied JS object.
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
const preconfigureFn = lib.func(
|
|
81
|
+
'int smartspectra_preconfigure(const char *directory, ' +
|
|
82
|
+
'const char *device_id, _Out_ smartspectra_error_t *out_err)');
|
|
83
|
+
|
|
84
|
+
const sessionCreate = lib.func(
|
|
85
|
+
'smartspectra_session_t *smartspectra_session_create(' +
|
|
86
|
+
'const smartspectra_config_t *cfg, uintptr_t handle, ' +
|
|
87
|
+
'const smartspectra_callbacks_t *callbacks, ' +
|
|
88
|
+
'_Out_ smartspectra_error_t *out_err)');
|
|
89
|
+
|
|
90
|
+
// Source option structs — field order matches smartspectra_capi.h (locked by
|
|
91
|
+
// its layout asserts).
|
|
92
|
+
koffi.struct('smartspectra_file_options_t', {
|
|
93
|
+
interframe_delay_ms: 'int',
|
|
94
|
+
start_offset_ms: 'int',
|
|
95
|
+
max_duration_ms: 'int',
|
|
96
|
+
frame_transform: 'int',
|
|
97
|
+
});
|
|
98
|
+
koffi.struct('smartspectra_camera_options_t', {
|
|
99
|
+
device_index: 'int',
|
|
100
|
+
width: 'int',
|
|
101
|
+
height: 'int',
|
|
102
|
+
fps: 'int',
|
|
103
|
+
frame_transform: 'int',
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Non-blocking file source: configure + start, returns immediately. Caller
|
|
107
|
+
// blocks via sessionWait when it wants end-of-file.
|
|
108
|
+
const sessionStartFile = lib.func(
|
|
109
|
+
'int smartspectra_session_start_file(smartspectra_session_t *sess, ' +
|
|
110
|
+
'const char *video_path, const char *timestamps_path, ' +
|
|
111
|
+
'const smartspectra_file_options_t *opts, _Out_ smartspectra_error_t *out_err)');
|
|
112
|
+
|
|
113
|
+
const sessionStartCamera = lib.func(
|
|
114
|
+
'int smartspectra_session_start_camera(smartspectra_session_t *sess, ' +
|
|
115
|
+
'const smartspectra_camera_options_t *opts, _Out_ smartspectra_error_t *out_err)');
|
|
116
|
+
|
|
117
|
+
const sessionStartCustom = lib.func(
|
|
118
|
+
'int smartspectra_session_start_custom(smartspectra_session_t *sess, ' +
|
|
119
|
+
'int frame_transform, _Out_ smartspectra_error_t *out_err)');
|
|
120
|
+
|
|
121
|
+
const sessionSendFrame = lib.func(
|
|
122
|
+
'int smartspectra_session_send_frame(smartspectra_session_t *sess, ' +
|
|
123
|
+
'const uint8_t *data, size_t data_len, int width, int height, ' +
|
|
124
|
+
'int stride_bytes, int pixel_format, int64_t timestamp_us, ' +
|
|
125
|
+
'_Out_ smartspectra_error_t *out_err)');
|
|
126
|
+
|
|
127
|
+
const sessionStop = lib.func('void smartspectra_session_stop(smartspectra_session_t *sess)');
|
|
128
|
+
const sessionWait = lib.func('int smartspectra_session_wait(smartspectra_session_t *sess, int timeout_ms)');
|
|
129
|
+
const sessionGetStatus = lib.func('int smartspectra_session_get_status(smartspectra_session_t *sess)');
|
|
130
|
+
const sessionReset = lib.func(
|
|
131
|
+
'int smartspectra_session_reset(smartspectra_session_t *sess, ' +
|
|
132
|
+
'_Out_ smartspectra_error_t *out_err)');
|
|
133
|
+
const sessionRequestInsight = lib.func(
|
|
134
|
+
'int smartspectra_session_request_insight(smartspectra_session_t *sess, ' +
|
|
135
|
+
'const char *text, _Out_ int32_t *out_request_id, ' +
|
|
136
|
+
'_Out_ smartspectra_error_t *out_err)');
|
|
137
|
+
const sessionDestroy = lib.func('void smartspectra_session_destroy(smartspectra_session_t *sess)');
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Trampoline adapters. `const char *` args are auto-decoded by koffi to JS
|
|
141
|
+
// strings (don't call koffi.decode — throws "expected external or
|
|
142
|
+
// TypedArray"). `const uint8_t *` is NOT auto-decoded (no length); we
|
|
143
|
+
// explicitly decode + copy into a fresh Buffer so the user can retain the
|
|
144
|
+
// data past callback return (the C-side pointer is borrowed for the call
|
|
145
|
+
// only).
|
|
146
|
+
//
|
|
147
|
+
// Plain functions, not class methods, so a future edit can't introduce a
|
|
148
|
+
// hidden `this` dependency.
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
function wrapStatus(jsFn) {
|
|
152
|
+
return (_handle, status) => jsFn(status);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function wrapValidation(jsFn) {
|
|
156
|
+
return (_handle, code, hint, timestampUs) => jsFn(code, hint ?? '', timestampUs);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function wrapMetrics(jsFn) {
|
|
160
|
+
return (_handle, bytesPtr, length, timestampUs) => {
|
|
161
|
+
const len = length | 0;
|
|
162
|
+
const buf = len > 0
|
|
163
|
+
? Buffer.from(koffi.decode(bytesPtr, koffi.array('uint8_t', len)))
|
|
164
|
+
: Buffer.alloc(0);
|
|
165
|
+
jsFn(buf, timestampUs);
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function wrapError(jsFn) {
|
|
170
|
+
return (_handle, code, message, retryable) =>
|
|
171
|
+
jsFn(code, message ?? '', retryable !== 0);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function wrapFrame(jsFn) {
|
|
175
|
+
return (_handle, sent, timestampUs) => jsFn(sent !== 0, timestampUs);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function wrapInsight(jsFn) {
|
|
179
|
+
return (_handle, bytesPtr, length, requestId) => {
|
|
180
|
+
const len = length | 0;
|
|
181
|
+
const buf = len > 0
|
|
182
|
+
? Buffer.from(koffi.decode(bytesPtr, koffi.array('uint8_t', len)))
|
|
183
|
+
: Buffer.alloc(0);
|
|
184
|
+
jsFn(buf, requestId | 0);
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Bytes occupied by a frame buffer, mirroring smartspectra_session_send_frame's
|
|
189
|
+
// size rule: packed formats span stride_bytes * height; planar YUV (NV12=4,
|
|
190
|
+
// NV21=5) adds a half-height chroma plane at the same stride.
|
|
191
|
+
function frameByteLength(height, strideBytes, pixelFormat) {
|
|
192
|
+
if (pixelFormat === 4 || pixelFormat === 5) {
|
|
193
|
+
return strideBytes * height + strideBytes * Math.ceil(height / 2);
|
|
194
|
+
}
|
|
195
|
+
return strideBytes * height;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function wrapVideoOutput(jsFn) {
|
|
199
|
+
return (_handle, dataPtr, width, height, strideBytes, pixelFormat, timestampUs) => {
|
|
200
|
+
const w = width | 0, h = height | 0, stride = strideBytes | 0, fmt = pixelFormat | 0;
|
|
201
|
+
const len = frameByteLength(h, stride, fmt);
|
|
202
|
+
// Copy the borrowed pixel buffer so the consumer can retain it past
|
|
203
|
+
// callback return (same contract as wrapMetrics).
|
|
204
|
+
const buf = len > 0
|
|
205
|
+
? Buffer.from(koffi.decode(dataPtr, koffi.array('uint8_t', len)))
|
|
206
|
+
: Buffer.alloc(0);
|
|
207
|
+
jsFn(buf, w, h, stride, fmt, timestampUs);
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Coerce a sendFrame() timestamp into a BigInt int64. Throws up front for
|
|
212
|
+
// non-finite/non-numeric input so the caller sees an actionable message
|
|
213
|
+
// instead of an opaque BigInt() RangeError from inside the FFI call.
|
|
214
|
+
function toMicroseconds(timestampUs) {
|
|
215
|
+
if (typeof timestampUs === 'bigint') return timestampUs;
|
|
216
|
+
if (typeof timestampUs !== 'number' || !Number.isFinite(timestampUs)) {
|
|
217
|
+
throw new TypeError(
|
|
218
|
+
'sendFrame: timestampUs must be a finite number or bigint (microseconds)');
|
|
219
|
+
}
|
|
220
|
+
return BigInt(Math.trunc(timestampUs));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
// Session — thin wrapper over the C ABI. Owns the opaque C session pointer
|
|
225
|
+
// and the set of koffi-registered callback thunks.
|
|
226
|
+
//
|
|
227
|
+
// Cleanup ordering (load-bearing):
|
|
228
|
+
// stop() → wait() → destroy() → unregister(thunks)
|
|
229
|
+
// destroy() joins SDK worker threads before returning, so unregistering
|
|
230
|
+
// after destroy() is safe. Doing it before would invalidate trampolines
|
|
231
|
+
// while threads are still tearing down.
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
class Session {
|
|
235
|
+
/**
|
|
236
|
+
* @param {Object} cfg
|
|
237
|
+
* @param {string} cfg.apiKey
|
|
238
|
+
* @param {number[]} [cfg.requestedMetrics]
|
|
239
|
+
* @param {boolean} [cfg.enableAccumulatedOutput=false]
|
|
240
|
+
* @param {Object} callbacks
|
|
241
|
+
* @param {(status: number) => void} [callbacks.onStatus]
|
|
242
|
+
* @param {(code: number, hint: string, timestampUs: bigint) => void} [callbacks.onValidation]
|
|
243
|
+
* @param {(bytes: Buffer, timestampUs: bigint) => void} callbacks.onMetrics (required)
|
|
244
|
+
* @param {(code: number, message: string, retryable: boolean) => void} [callbacks.onError]
|
|
245
|
+
* @param {(sent: boolean, timestampUs: bigint) => void} [callbacks.onFrame]
|
|
246
|
+
* @param {(bytes: Buffer, timestampUs: bigint) => void} [callbacks.onAccumulatedMetrics]
|
|
247
|
+
* @param {(bytes: Buffer, requestId: number) => void} [callbacks.onInsight]
|
|
248
|
+
* @param {(bytes: Buffer, width: number, height: number, stride: number, pixelFormat: number, timestampUs: bigint) => void} [callbacks.onVideoOutput]
|
|
249
|
+
*/
|
|
250
|
+
constructor(cfg, callbacks) {
|
|
251
|
+
if (!callbacks || typeof callbacks.onMetrics !== 'function') {
|
|
252
|
+
throw new TypeError('Session: callbacks.onMetrics is required');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
this._registeredThunks = [];
|
|
256
|
+
this._destroyed = false;
|
|
257
|
+
// In-flight stopAsync() promise, if any. session_stop runs on a koffi
|
|
258
|
+
// worker thread; destroy() must wait for it to settle before deleting
|
|
259
|
+
// the session (see stopAsync / destroy below).
|
|
260
|
+
this._stopAsyncPromise = null;
|
|
261
|
+
// Resolves once native teardown has actually completed, so callers can
|
|
262
|
+
// serialize a replacement session against this one's wind-down.
|
|
263
|
+
this._teardownPromise = null;
|
|
264
|
+
|
|
265
|
+
// Null slots in the C struct mean the shim won't attempt to dispatch.
|
|
266
|
+
const cbs = {
|
|
267
|
+
on_status: this._registerThunk(callbacks.onStatus, 'smartspectra_on_status *', wrapStatus),
|
|
268
|
+
on_validation: this._registerThunk(callbacks.onValidation, 'smartspectra_on_validation *', wrapValidation),
|
|
269
|
+
on_metrics: this._registerThunk(callbacks.onMetrics, 'smartspectra_on_metrics *', wrapMetrics),
|
|
270
|
+
on_error: this._registerThunk(callbacks.onError, 'smartspectra_on_error *', wrapError),
|
|
271
|
+
on_frame: this._registerThunk(callbacks.onFrame, 'smartspectra_on_frame *', wrapFrame),
|
|
272
|
+
on_accumulated_metrics: this._registerThunk(callbacks.onAccumulatedMetrics, 'smartspectra_on_accumulated_metrics *', wrapMetrics),
|
|
273
|
+
on_insight: this._registerThunk(callbacks.onInsight, 'smartspectra_on_insight *', wrapInsight),
|
|
274
|
+
on_video_output: this._registerThunk(callbacks.onVideoOutput, 'smartspectra_on_video_output *', wrapVideoOutput),
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// Keep the Int32Array on `this` until session_create returns so the
|
|
278
|
+
// GC can't recycle the buffer mid-call.
|
|
279
|
+
const requested = cfg.requestedMetrics ?? [];
|
|
280
|
+
this._requestedMetricsBuf = requested.length > 0
|
|
281
|
+
? new Int32Array(requested)
|
|
282
|
+
: null;
|
|
283
|
+
|
|
284
|
+
const cConfig = {
|
|
285
|
+
api_key: cfg.apiKey ?? '',
|
|
286
|
+
requested_metrics: this._requestedMetricsBuf,
|
|
287
|
+
requested_metrics_len: requested.length,
|
|
288
|
+
enable_accumulated_output: cfg.enableAccumulatedOutput ? 1 : 0,
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const err = {};
|
|
292
|
+
// `handle` is an opaque cookie threaded back into callbacks. Each
|
|
293
|
+
// Session captures `this` via the trampoline closures, so 0 is fine.
|
|
294
|
+
const sess = sessionCreate(cConfig, 0n, cbs, err);
|
|
295
|
+
if (sess === null) {
|
|
296
|
+
this._releaseThunks();
|
|
297
|
+
throw makeFfiError(err);
|
|
298
|
+
}
|
|
299
|
+
this._sess = sess;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Non-blocking file source. timestampsPath + options (optional):
|
|
303
|
+
// { interframeDelayMs, startOffsetMs, maxDurationMs, frameTransform }.
|
|
304
|
+
startFile(videoPath, timestampsPath = null, options = null) {
|
|
305
|
+
this._assertAlive();
|
|
306
|
+
const err = {};
|
|
307
|
+
const opts = options ? {
|
|
308
|
+
interframe_delay_ms: options.interframeDelayMs | 0,
|
|
309
|
+
start_offset_ms: options.startOffsetMs | 0,
|
|
310
|
+
max_duration_ms: options.maxDurationMs | 0,
|
|
311
|
+
frame_transform: options.frameTransform | 0,
|
|
312
|
+
} : null;
|
|
313
|
+
const rc = sessionStartFile(this._sess, videoPath, timestampsPath, opts, err);
|
|
314
|
+
if (rc !== 0) throw makeFfiError(err);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// options (optional): { deviceIndex, width, height, fps, frameTransform }
|
|
318
|
+
startCamera(options = null) {
|
|
319
|
+
this._assertAlive();
|
|
320
|
+
const err = {};
|
|
321
|
+
const opts = options ? {
|
|
322
|
+
device_index: options.deviceIndex | 0,
|
|
323
|
+
width: options.width | 0,
|
|
324
|
+
height: options.height | 0,
|
|
325
|
+
fps: options.fps | 0,
|
|
326
|
+
frame_transform: options.frameTransform | 0,
|
|
327
|
+
} : null;
|
|
328
|
+
const rc = sessionStartCamera(this._sess, opts, err);
|
|
329
|
+
if (rc !== 0) throw makeFfiError(err);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
startCustom(frameTransform = 0) {
|
|
333
|
+
this._assertAlive();
|
|
334
|
+
const err = {};
|
|
335
|
+
const rc = sessionStartCustom(this._sess, frameTransform | 0, err);
|
|
336
|
+
if (rc !== 0) throw makeFfiError(err);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
sendFrame(buffer, width, height, strideBytes, pixelFormat, timestampUs) {
|
|
340
|
+
this._assertAlive();
|
|
341
|
+
const err = {};
|
|
342
|
+
const rc = sessionSendFrame(
|
|
343
|
+
this._sess, buffer, buffer.byteLength,
|
|
344
|
+
width | 0, height | 0, strideBytes | 0, pixelFormat | 0,
|
|
345
|
+
toMicroseconds(timestampUs), err);
|
|
346
|
+
if (rc !== 0) throw makeFfiError(err);
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
stop() {
|
|
351
|
+
if (this._destroyed) return;
|
|
352
|
+
sessionStop(this._sess);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Async variant of `stop()`. `SmartSpectra::Stop()` blocks until the
|
|
357
|
+
* processing pipeline drains — can take multiple seconds with inference
|
|
358
|
+
* in flight. Run on a koffi worker thread so the JS event loop stays
|
|
359
|
+
* responsive during the drain.
|
|
360
|
+
*/
|
|
361
|
+
stopAsync() {
|
|
362
|
+
if (this._destroyed) return Promise.resolve();
|
|
363
|
+
// Dedupe: a second stopAsync() while one is still draining returns the
|
|
364
|
+
// same promise rather than launching a second session_stop.
|
|
365
|
+
if (this._stopAsyncPromise) return this._stopAsyncPromise;
|
|
366
|
+
const p = new Promise((resolve, reject) => {
|
|
367
|
+
sessionStop.async(this._sess, (err) => {
|
|
368
|
+
if (err) reject(err);
|
|
369
|
+
else resolve();
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
this._stopAsyncPromise = p;
|
|
373
|
+
const clear = () => { if (this._stopAsyncPromise === p) this._stopAsyncPromise = null; };
|
|
374
|
+
p.then(clear, clear);
|
|
375
|
+
return p;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
wait(timeoutMs = 0) {
|
|
379
|
+
if (this._destroyed) return true;
|
|
380
|
+
return sessionWait(this._sess, timeoutMs | 0) !== 0;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
getStatus() {
|
|
384
|
+
if (this._destroyed) return 0; // kUninitialized
|
|
385
|
+
return sessionGetStatus(this._sess);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
reset() {
|
|
389
|
+
this._assertAlive();
|
|
390
|
+
const err = {};
|
|
391
|
+
const rc = sessionReset(this._sess, err);
|
|
392
|
+
if (rc !== 0) throw makeFfiError(err);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Dispatch an on-demand insight prompt; returns the request id. The
|
|
397
|
+
* matching Insight response is delivered asynchronously through the
|
|
398
|
+
* `onInsight` callback supplied at construction time.
|
|
399
|
+
*/
|
|
400
|
+
requestInsight(text) {
|
|
401
|
+
this._assertAlive();
|
|
402
|
+
if (typeof text !== 'string') {
|
|
403
|
+
throw new TypeError('requestInsight: text must be a string');
|
|
404
|
+
}
|
|
405
|
+
const err = {};
|
|
406
|
+
// koffi marshals the typed-array view as a writable pointer to the
|
|
407
|
+
// first element and writes the int32_t result into slot 0.
|
|
408
|
+
const out = new Int32Array(1);
|
|
409
|
+
const rc = sessionRequestInsight(this._sess, text, out, err);
|
|
410
|
+
if (rc !== 0) throw makeFfiError(err);
|
|
411
|
+
return out[0];
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Tear down the native session. Returns a promise that resolves once the
|
|
416
|
+
* native teardown has completed — callers serializing a replacement
|
|
417
|
+
* session must await it, because SDK state is process-global and two
|
|
418
|
+
* sessions must not overlap. Idempotent.
|
|
419
|
+
*/
|
|
420
|
+
destroy() {
|
|
421
|
+
if (this._destroyed) return this._teardownPromise || Promise.resolve();
|
|
422
|
+
this._destroyed = true;
|
|
423
|
+
// A stopAsync() may still be draining the graph on a koffi worker
|
|
424
|
+
// thread. Deleting the session (and unregistering its trampolines)
|
|
425
|
+
// while that thread is inside session_stop is a use-after-free, so
|
|
426
|
+
// defer the native teardown until the in-flight stop settles. The
|
|
427
|
+
// _destroyed guard already blocks any new stop from starting.
|
|
428
|
+
if (this._stopAsyncPromise) {
|
|
429
|
+
this._teardownPromise = this._stopAsyncPromise.then(
|
|
430
|
+
() => this._teardownNative(),
|
|
431
|
+
() => this._teardownNative());
|
|
432
|
+
} else {
|
|
433
|
+
this._teardownNative();
|
|
434
|
+
this._teardownPromise = Promise.resolve();
|
|
435
|
+
}
|
|
436
|
+
return this._teardownPromise;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
_teardownNative() {
|
|
440
|
+
if (this._sess === null) return;
|
|
441
|
+
// stop → wait → destroy → unregister. session_destroy joins worker
|
|
442
|
+
// threads, so no SDK thread can call our trampolines after it returns.
|
|
443
|
+
sessionStop(this._sess);
|
|
444
|
+
sessionWait(this._sess, 0);
|
|
445
|
+
sessionDestroy(this._sess);
|
|
446
|
+
this._sess = null;
|
|
447
|
+
this._releaseThunks();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ---------- internal ----------------------------------------------------
|
|
451
|
+
|
|
452
|
+
_assertAlive() {
|
|
453
|
+
if (this._destroyed) throw new Error('Session has been destroyed');
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
_registerThunk(jsFn, protoTypeName, makeAdapter) {
|
|
457
|
+
if (typeof jsFn !== 'function') return null;
|
|
458
|
+
const thunk = koffi.register(makeAdapter(jsFn), protoTypeName);
|
|
459
|
+
this._registeredThunks.push(thunk);
|
|
460
|
+
return thunk;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
_releaseThunks() {
|
|
464
|
+
for (const t of this._registeredThunks) koffi.unregister(t);
|
|
465
|
+
this._registeredThunks = [];
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Wrap a populated smartspectra_error_t into a JS Error with numeric `code`
|
|
470
|
+
// and boolean `retryable`.
|
|
471
|
+
function makeFfiError(err) {
|
|
472
|
+
const msg = err.message || `SmartSpectra error code ${err.code}`;
|
|
473
|
+
const e = new Error(msg);
|
|
474
|
+
e.code = err.code;
|
|
475
|
+
e.retryable = err.retryable !== 0;
|
|
476
|
+
return e;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Internal one-time SDK initialization. Called by the Electron main-process
|
|
480
|
+
// glue in `@smartspectra/node-sdk/main`. Not part of the package's public
|
|
481
|
+
// exports map — host apps don't reach for it.
|
|
482
|
+
function preconfigure(sdkDirectory, deviceId) {
|
|
483
|
+
if (typeof sdkDirectory !== 'string' || sdkDirectory.length === 0) {
|
|
484
|
+
throw new TypeError('preconfigure: directory must be a non-empty string');
|
|
485
|
+
}
|
|
486
|
+
const err = {};
|
|
487
|
+
const rc = preconfigureFn(sdkDirectory, deviceId || null, err);
|
|
488
|
+
if (rc !== 0) throw makeFfiError(err);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
module.exports = {
|
|
492
|
+
Session,
|
|
493
|
+
preconfigure,
|
|
494
|
+
};
|