space-data-module-sdk 0.5.18 → 0.5.21

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,275 @@
1
+ /**
2
+ * Browser-side module harness.
3
+ *
4
+ * Loads the same standalone WASI .wasm artifact that WasmEdge runs,
5
+ * instantiating it in the browser with the WASI shim + optional sdn_host
6
+ * bridge. Matches the createModuleHarness() API surface.
7
+ *
8
+ * Supports two invoke paths:
9
+ * 1. "direct" — call plugin_invoke_stream(ptr, len, &outLen) and read
10
+ * the FlatBuffer response from WASM memory.
11
+ * 2. "command" — call _start() with stdin piped via WASI shim, read
12
+ * stdout for the response bytes.
13
+ */
14
+
15
+ import { createBrowserWasiShim, WasiExitError } from "../host/wasiShim.js";
16
+ import { createBrowserHost } from "../host/browserHost.js";
17
+ import {
18
+ createJsonHostcallBridge,
19
+ createNodeHostSyncDispatcher,
20
+ DEFAULT_HOSTCALL_IMPORT_MODULE,
21
+ } from "../host/abi.js";
22
+ import {
23
+ DefaultInvokeExports,
24
+ DefaultManifestExports,
25
+ } from "../runtime/constants.js";
26
+ import {
27
+ encodePluginInvokeRequest,
28
+ decodePluginInvokeResponse,
29
+ } from "../invoke/codec.js";
30
+
31
+ /**
32
+ * Detect artifact profile from WebAssembly.Module imports.
33
+ * Returns "standalone" (WASI-only), "sdn-abi" (WASI + sdn_host),
34
+ * or "emscripten" (env.* with invoke trampolines).
35
+ */
36
+ export function detectArtifactProfile(wasmModule) {
37
+ const imports = WebAssembly.Module.imports(wasmModule);
38
+ const moduleNames = new Set(imports.map((i) => i.module));
39
+
40
+ if (moduleNames.has("env")) {
41
+ const envImports = imports.filter((i) => i.module === "env");
42
+ const hasInvokeTrampolines = envImports.some((i) => i.name.startsWith("invoke_"));
43
+ const hasPthreads = envImports.some(
44
+ (i) => i.name.includes("pthread") || i.name.includes("thread"),
45
+ );
46
+ if (hasInvokeTrampolines || hasPthreads) {
47
+ return "emscripten";
48
+ }
49
+ }
50
+
51
+ if (moduleNames.has(DEFAULT_HOSTCALL_IMPORT_MODULE)) {
52
+ return "sdn-abi";
53
+ }
54
+
55
+ if (moduleNames.has("wasi_snapshot_preview1") || moduleNames.has("wasi_unstable")) {
56
+ return "standalone";
57
+ }
58
+
59
+ return "unknown";
60
+ }
61
+
62
+ async function compileWasmModule(source) {
63
+ if (source instanceof WebAssembly.Module) {
64
+ return source;
65
+ }
66
+ if (source instanceof Response) {
67
+ return WebAssembly.compileStreaming(source);
68
+ }
69
+ if (typeof source === "string") {
70
+ return WebAssembly.compileStreaming(fetch(source));
71
+ }
72
+ const bytes = source instanceof ArrayBuffer ? new Uint8Array(source) : source;
73
+ return WebAssembly.compile(bytes);
74
+ }
75
+
76
+ async function instantiateBrowserModule(options = {}) {
77
+ const wasi = createBrowserWasiShim({
78
+ args: options.args ?? [],
79
+ env: options.env ?? {},
80
+ stdinBytes: options.stdinBytes ?? new Uint8Array(),
81
+ logOutput: options.logOutput === true,
82
+ performance: options.performance,
83
+ });
84
+ const importObject = { ...wasi.imports };
85
+ const moduleImports = WebAssembly.Module.imports(options.wasmModule);
86
+ const needsHostBridge = moduleImports.some(
87
+ (entry) => entry.module === DEFAULT_HOSTCALL_IMPORT_MODULE,
88
+ );
89
+
90
+ let instance = null;
91
+ let bridge = null;
92
+ if (needsHostBridge) {
93
+ const dispatch = createNodeHostSyncDispatcher(options.host);
94
+ bridge = createJsonHostcallBridge({
95
+ dispatch,
96
+ getMemory: () => instance.exports.memory,
97
+ });
98
+ Object.assign(importObject, bridge.imports);
99
+ }
100
+
101
+ instance = await WebAssembly.instantiate(options.wasmModule, importObject);
102
+ if (instance.exports.memory) {
103
+ wasi.setMemory(instance.exports.memory);
104
+ }
105
+ if (instance.exports._initialize) {
106
+ instance.exports._initialize();
107
+ }
108
+
109
+ return {
110
+ instance,
111
+ bridge,
112
+ wasi,
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Create a browser-side module harness for a standalone WASI artifact.
118
+ *
119
+ * @param {Object} options
120
+ * @param {Uint8Array|ArrayBuffer|Response|string} options.wasmSource
121
+ * WASM bytes, ArrayBuffer, fetch Response, or URL string.
122
+ * @param {Object} [options.host] - BrowserHost instance (created if omitted).
123
+ * @param {string[]} [options.args] - WASI args passed to the module.
124
+ * @param {Object} [options.env] - WASI environment variables.
125
+ * @param {string} [options.surface] - "direct" or "command" (default: auto-detect).
126
+ */
127
+ export async function createBrowserModuleHarness(options = {}) {
128
+ const host = options.host ?? createBrowserHost(options.hostOptions);
129
+ const wasmModule = await compileWasmModule(options.wasmSource);
130
+
131
+ const profile = detectArtifactProfile(wasmModule);
132
+ const moduleExports = WebAssembly.Module.exports(wasmModule);
133
+ const exportNames = new Set(moduleExports.map((e) => e.name));
134
+
135
+ const hasDirectInvoke = exportNames.has(DefaultInvokeExports.invokeSymbol);
136
+ const hasCommand = exportNames.has(DefaultInvokeExports.commandSymbol);
137
+ const surface =
138
+ options.surface ?? (hasDirectInvoke ? "direct" : hasCommand ? "command" : "direct");
139
+ if (profile === "emscripten") {
140
+ throw new Error(
141
+ "Browser harness only supports standalone WASI or sdn_host artifacts. " +
142
+ 'Compile shared browser/WasmEdge modules with runtimeTargets: ["browser", "wasmedge"] ' +
143
+ 'or override threadModel to "single-thread".',
144
+ );
145
+ }
146
+
147
+ const activeContext = await instantiateBrowserModule({
148
+ wasmModule,
149
+ host,
150
+ args: options.args,
151
+ env: options.env,
152
+ performance: options.performance ?? host?.performance,
153
+ logOutput: options.logOutput === true,
154
+ });
155
+ const { instance, bridge, wasi } = activeContext;
156
+
157
+ // --- Invoke helpers ---
158
+ function invokeDirectRaw(requestBytes) {
159
+ const alloc = instance.exports[DefaultInvokeExports.allocSymbol];
160
+ const free = instance.exports[DefaultInvokeExports.freeSymbol];
161
+ const invokeStream = instance.exports[DefaultInvokeExports.invokeSymbol];
162
+ const memory = instance.exports.memory;
163
+ if (
164
+ typeof alloc !== "function" ||
165
+ typeof free !== "function" ||
166
+ typeof invokeStream !== "function" ||
167
+ !memory
168
+ ) {
169
+ throw new Error(
170
+ "Direct browser invoke requires plugin_alloc, plugin_free, plugin_invoke_stream, and memory exports.",
171
+ );
172
+ }
173
+ const reqLen = requestBytes.length;
174
+ const reqPtr = alloc(reqLen);
175
+ if (!reqPtr) throw new Error("plugin_alloc returned null for request.");
176
+
177
+ new Uint8Array(memory.buffer, reqPtr, reqLen).set(requestBytes);
178
+
179
+ // Allocate space for the response length output
180
+ const outLenPtr = alloc(4);
181
+ if (!outLenPtr) throw new Error("plugin_alloc returned null for response length.");
182
+
183
+ new DataView(memory.buffer).setUint32(outLenPtr, 0, true);
184
+
185
+ const resPtr = invokeStream(reqPtr, reqLen, outLenPtr);
186
+ const resLen = new DataView(memory.buffer).getUint32(outLenPtr, true);
187
+
188
+ free(reqPtr, reqLen);
189
+ free(outLenPtr, 4);
190
+
191
+ if (!resPtr || !resLen) {
192
+ throw new Error("plugin_invoke_stream returned null response.");
193
+ }
194
+
195
+ const responseBytes = new Uint8Array(memory.buffer, resPtr, resLen).slice();
196
+ free(resPtr, resLen);
197
+ return responseBytes;
198
+ }
199
+
200
+ async function invokeCommandRaw(stdinBytes) {
201
+ const commandContext = await instantiateBrowserModule({
202
+ wasmModule,
203
+ host,
204
+ args: options.args,
205
+ env: options.env,
206
+ stdinBytes,
207
+ performance: options.performance ?? host?.performance,
208
+ logOutput: false,
209
+ });
210
+ try {
211
+ const commandExport = commandContext.instance.exports[DefaultInvokeExports.commandSymbol];
212
+ if (typeof commandExport !== "function") {
213
+ throw new Error(
214
+ `Command-surface browser invoke requires the ${DefaultInvokeExports.commandSymbol} export.`,
215
+ );
216
+ }
217
+ commandExport();
218
+ } catch (error) {
219
+ if (!(error instanceof WasiExitError) || error.code !== 0) {
220
+ throw error;
221
+ }
222
+ }
223
+ return commandContext.wasi.stdout;
224
+ }
225
+
226
+ // --- Public API ---
227
+
228
+ async function invokeRaw(requestBytes) {
229
+ if (surface === "command") {
230
+ return invokeCommandRaw(requestBytes);
231
+ }
232
+ return invokeDirectRaw(requestBytes);
233
+ }
234
+
235
+ async function invoke(request) {
236
+ const requestBytes = encodePluginInvokeRequest(request);
237
+ const responseBytes = await invokeRaw(requestBytes);
238
+ return decodePluginInvokeResponse(responseBytes);
239
+ }
240
+
241
+ function readManifest() {
242
+ const getBytesExport =
243
+ instance.exports[DefaultManifestExports.pluginBytesSymbol];
244
+ const getSizeExport =
245
+ instance.exports[DefaultManifestExports.pluginSizeSymbol];
246
+ if (!getBytesExport || !getSizeExport) return null;
247
+
248
+ const ptr = getBytesExport();
249
+ const size = getSizeExport();
250
+ if (!ptr || !size) return null;
251
+
252
+ return new Uint8Array(instance.exports.memory.buffer, ptr, size).slice();
253
+ }
254
+
255
+ function destroy() {
256
+ wasi.flushOutput();
257
+ }
258
+
259
+ return {
260
+ runtime: {
261
+ kind: "browser",
262
+ profile,
263
+ surface,
264
+ },
265
+ instance,
266
+ module: wasmModule,
267
+ host,
268
+ bridge,
269
+ wasi,
270
+ invoke,
271
+ invokeRaw,
272
+ readManifest,
273
+ destroy,
274
+ };
275
+ }
@@ -317,6 +317,40 @@ export function createPublicationProtectionDemoSummary(options?: {
317
317
  };
318
318
  }): Promise<PublicationProtectionDemoSummary>;
319
319
 
320
+ export interface BrowserModuleHarness {
321
+ runtime: {
322
+ kind: "browser";
323
+ profile: string;
324
+ surface: string;
325
+ };
326
+ instance: WebAssembly.Instance;
327
+ module: WebAssembly.Module;
328
+ host: unknown;
329
+ bridge: unknown;
330
+ wasi: {
331
+ imports: Record<string, Record<string, (...args: number[]) => number>>;
332
+ setMemory(mem: { buffer: ArrayBuffer | SharedArrayBuffer }): void;
333
+ getMemory(): { buffer: ArrayBuffer | SharedArrayBuffer } | null;
334
+ flushOutput(): void;
335
+ stdout: Uint8Array;
336
+ stderr: Uint8Array;
337
+ };
338
+ invokeRaw(
339
+ requestBytes: Uint8Array | ArrayBuffer | ArrayBufferView,
340
+ ): Promise<Uint8Array>;
341
+ invoke(request: {
342
+ methodId?: string | null;
343
+ inputs?: HarnessInputFrame[];
344
+ }): Promise<{
345
+ statusCode: number;
346
+ errorCode?: string | null;
347
+ errorMessage?: string | null;
348
+ outputs: HarnessInputFrame[];
349
+ }>;
350
+ readManifest(): Uint8Array | null;
351
+ destroy(): void;
352
+ }
353
+
320
354
  export function generateManifestHarnessPlan(options: {
321
355
  manifest: PluginManifest;
322
356
  includeOptionalInputs?: boolean;
@@ -375,6 +409,22 @@ export function resolveModuleHarnessLaunchPlan(options: {
375
409
  runtime?: ModuleHarnessRuntimeDescriptor;
376
410
  } | ModuleHarnessRuntimeDescriptor): PluginInvokeProcessLaunchPlan;
377
411
 
412
+ export function detectArtifactProfile(wasmModule: WebAssembly.Module): string;
413
+
414
+ export function createBrowserModuleHarness(options?: {
415
+ wasmSource: Uint8Array | ArrayBuffer | string | WebAssembly.Module | unknown;
416
+ host?: unknown;
417
+ hostOptions?: unknown;
418
+ args?: string[];
419
+ env?: Record<string, string>;
420
+ surface?: "direct" | "command";
421
+ performance?: {
422
+ now(): number;
423
+ timeOrigin: number;
424
+ };
425
+ logOutput?: boolean;
426
+ }): Promise<BrowserModuleHarness>;
427
+
378
428
  export function createModuleHarness(options: {
379
429
  runtime?: ModuleHarnessRuntimeDescriptor;
380
430
  } | ModuleHarnessRuntimeDescriptor): Promise<ModuleHarness>;
@@ -7,6 +7,10 @@ export {
7
7
  createPublicationProtectionDemoManifest,
8
8
  createPublicationProtectionDemoSummary,
9
9
  } from "./publicationProtectionDemo.js";
10
+ export {
11
+ createBrowserModuleHarness,
12
+ detectArtifactProfile,
13
+ } from "./browserModuleHarness.js";
10
14
  export {
11
15
  buildWasmEdgeSpawnEnv,
12
16
  createPluginInvokeProcessClient,
@@ -22,6 +26,7 @@ export {
22
26
  createModuleHarness,
23
27
  resolveModuleHarnessLaunchPlan,
24
28
  } from "./moduleHarness.js";
29
+ export { createModuleFlatBufferStreamPump } from "./moduleFlatbufferStreamPump.js";
25
30
 
26
31
  const CapabilitySurfaceMatrix = Object.freeze({
27
32
  logging: Object.freeze({
@@ -0,0 +1,255 @@
1
+ import { clonePayloadTypeRef } from "../manifest/typeRefs.js";
2
+
3
+ function assertNonEmptyString(value, label) {
4
+ const normalized = String(value ?? "").trim();
5
+ if (!normalized) {
6
+ throw new TypeError(`${label} is required.`);
7
+ }
8
+ return normalized;
9
+ }
10
+
11
+ function normalizePositiveInteger(value, fallback) {
12
+ const normalized = Number(value ?? fallback);
13
+ if (!Number.isInteger(normalized) || normalized <= 0) {
14
+ return fallback;
15
+ }
16
+ return normalized;
17
+ }
18
+
19
+ function toUint8Array(data) {
20
+ if (data instanceof Uint8Array) {
21
+ return data;
22
+ }
23
+ if (data instanceof ArrayBuffer) {
24
+ return new Uint8Array(data);
25
+ }
26
+ if (ArrayBuffer.isView(data)) {
27
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
28
+ }
29
+ throw new TypeError(
30
+ "Module FlatBuffer stream pump expects Uint8Array, ArrayBuffer, or ArrayBufferView chunks.",
31
+ );
32
+ }
33
+
34
+ function concatUint8Arrays(chunks) {
35
+ let totalLength = 0;
36
+ for (const chunk of chunks) {
37
+ totalLength += chunk.byteLength;
38
+ }
39
+ const combined = new Uint8Array(totalLength);
40
+ let offset = 0;
41
+ for (const chunk of chunks) {
42
+ combined.set(chunk, offset);
43
+ offset += chunk.byteLength;
44
+ }
45
+ return combined;
46
+ }
47
+
48
+ function readFrameSize(bytes, offset) {
49
+ return (
50
+ bytes[offset] |
51
+ (bytes[offset + 1] << 8) |
52
+ (bytes[offset + 2] << 16) |
53
+ ((bytes[offset + 3] << 24) >>> 0)
54
+ );
55
+ }
56
+
57
+ function readFileIdentifier(payload) {
58
+ if (!(payload instanceof Uint8Array) || payload.byteLength < 8) {
59
+ return null;
60
+ }
61
+ return String.fromCharCode(payload[4], payload[5], payload[6], payload[7]);
62
+ }
63
+
64
+ function normalizeSchemaFileId(fileIdentifier) {
65
+ if (typeof fileIdentifier !== "string") {
66
+ return null;
67
+ }
68
+ const normalized = fileIdentifier.trim();
69
+ return normalized.length > 0 ? normalized : null;
70
+ }
71
+
72
+ function resolveInvoke(options) {
73
+ if (typeof options.invoke === "function") {
74
+ return options.invoke;
75
+ }
76
+ if (options.harness && typeof options.harness.invoke === "function") {
77
+ return options.harness.invoke.bind(options.harness);
78
+ }
79
+ throw new TypeError(
80
+ "createModuleFlatBufferStreamPump requires an invoke(request) function or harness with invoke().",
81
+ );
82
+ }
83
+
84
+ function defaultTypeResolver(_payload, context) {
85
+ return {
86
+ schemaName: null,
87
+ fileIdentifier: context.rawFileIdentifier,
88
+ acceptsAnyFlatbuffer: true,
89
+ };
90
+ }
91
+
92
+ function resolveFrameTemplate(frameTemplate, payload, context) {
93
+ if (typeof frameTemplate === "function") {
94
+ const resolved = frameTemplate(payload, context);
95
+ return resolved && typeof resolved === "object" ? resolved : {};
96
+ }
97
+ if (frameTemplate && typeof frameTemplate === "object") {
98
+ return frameTemplate;
99
+ }
100
+ return {};
101
+ }
102
+
103
+ export function createModuleFlatBufferStreamPump(options = {}) {
104
+ const invoke = resolveInvoke(options);
105
+ const methodId = assertNonEmptyString(options.methodId, "methodId");
106
+ const portId = assertNonEmptyString(options.portId, "portId");
107
+ const maxFramesPerInvoke = normalizePositiveInteger(options.maxFramesPerInvoke, 1);
108
+ const streamId = normalizePositiveInteger(options.streamId, 1);
109
+ const typeResolver =
110
+ typeof options.typeResolver === "function"
111
+ ? options.typeResolver
112
+ : defaultTypeResolver;
113
+ const onResponse =
114
+ typeof options.onResponse === "function" ? options.onResponse : null;
115
+
116
+ const stats = {
117
+ bytesReceived: 0,
118
+ chunksReceived: 0,
119
+ framesDecoded: 0,
120
+ framesInvoked: 0,
121
+ invokes: 0,
122
+ parseErrors: 0,
123
+ };
124
+
125
+ let pendingBytes = new Uint8Array(0);
126
+ let pendingFrames = [];
127
+ let nextSequence = normalizePositiveInteger(options.sequenceStart, 1);
128
+ let lastResponse = null;
129
+
130
+ async function flushPendingFrames(isFinalBatch) {
131
+ if (pendingFrames.length === 0) {
132
+ return lastResponse;
133
+ }
134
+
135
+ const frames = pendingFrames.map((decodedFrame, index) => {
136
+ const context = {
137
+ rawFileIdentifier: decodedFrame.rawFileIdentifier,
138
+ schemaFileId: decodedFrame.schemaFileId,
139
+ methodId,
140
+ portId,
141
+ streamId: decodedFrame.streamId,
142
+ sequence: decodedFrame.sequence,
143
+ stats,
144
+ };
145
+ const resolvedTypeRef = clonePayloadTypeRef(
146
+ typeResolver(decodedFrame.payload, context) ?? defaultTypeResolver(decodedFrame.payload, context),
147
+ );
148
+ if (!resolvedTypeRef.fileIdentifier && decodedFrame.rawFileIdentifier) {
149
+ resolvedTypeRef.fileIdentifier = decodedFrame.rawFileIdentifier;
150
+ }
151
+ if (resolvedTypeRef.acceptsAnyFlatbuffer !== true) {
152
+ resolvedTypeRef.acceptsAnyFlatbuffer = false;
153
+ }
154
+ return {
155
+ ...resolveFrameTemplate(options.frameTemplate, decodedFrame.payload, context),
156
+ portId,
157
+ typeRef: resolvedTypeRef,
158
+ payload: decodedFrame.payload,
159
+ streamId: decodedFrame.streamId,
160
+ sequence: decodedFrame.sequence,
161
+ endOfStream:
162
+ isFinalBatch === true && index === pendingFrames.length - 1,
163
+ };
164
+ });
165
+
166
+ pendingFrames = [];
167
+ const response = await invoke({
168
+ methodId,
169
+ inputs: frames,
170
+ });
171
+ stats.invokes += 1;
172
+ stats.framesInvoked += frames.length;
173
+ lastResponse = response;
174
+ if (onResponse) {
175
+ await onResponse(response, {
176
+ methodId,
177
+ portId,
178
+ frames,
179
+ isFinalBatch: isFinalBatch === true,
180
+ stats,
181
+ });
182
+ }
183
+ return response;
184
+ }
185
+
186
+ async function pushBytes(data) {
187
+ const bytes = toUint8Array(data);
188
+ stats.bytesReceived += bytes.byteLength;
189
+ stats.chunksReceived += 1;
190
+
191
+ const combined =
192
+ pendingBytes.byteLength > 0 ? concatUint8Arrays([pendingBytes, bytes]) : bytes;
193
+
194
+ let offset = 0;
195
+ let decodedCount = 0;
196
+
197
+ while (offset + 4 <= combined.byteLength) {
198
+ const frameSize = readFrameSize(combined, offset);
199
+ if (frameSize <= 0) {
200
+ stats.parseErrors += 1;
201
+ throw new Error("Invalid FlatBuffer stream frame size.");
202
+ }
203
+ if (offset + 4 + frameSize > combined.byteLength) {
204
+ break;
205
+ }
206
+
207
+ const payload = combined.subarray(offset + 4, offset + 4 + frameSize).slice();
208
+ const rawFileIdentifier = readFileIdentifier(payload);
209
+ const schemaFileId = normalizeSchemaFileId(rawFileIdentifier);
210
+ if (!schemaFileId) {
211
+ stats.parseErrors += 1;
212
+ throw new Error(
213
+ "FlatBuffer stream frame is missing a readable file identifier.",
214
+ );
215
+ }
216
+
217
+ pendingFrames.push({
218
+ payload,
219
+ rawFileIdentifier,
220
+ schemaFileId,
221
+ streamId,
222
+ sequence: nextSequence,
223
+ });
224
+ nextSequence += 1;
225
+ stats.framesDecoded += 1;
226
+ decodedCount += 1;
227
+ offset += 4 + frameSize;
228
+
229
+ if (pendingFrames.length >= maxFramesPerInvoke) {
230
+ await flushPendingFrames(false);
231
+ }
232
+ }
233
+
234
+ pendingBytes =
235
+ offset < combined.byteLength ? combined.slice(offset) : new Uint8Array(0);
236
+
237
+ return decodedCount;
238
+ }
239
+
240
+ async function finish() {
241
+ if (pendingBytes.byteLength > 0) {
242
+ throw new Error("FlatBuffer stream ended with a partial frame.");
243
+ }
244
+ return flushPendingFrames(true);
245
+ }
246
+
247
+ return {
248
+ stats,
249
+ get lastResponse() {
250
+ return lastResponse;
251
+ },
252
+ pushBytes,
253
+ finish,
254
+ };
255
+ }