space-data-module-sdk 0.5.22 → 0.6.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.
package/README.md CHANGED
@@ -176,8 +176,15 @@ This repo currently includes:
176
176
  - non-JS bundle reference clients in
177
177
  [`examples/single-file-bundle/go`](./examples/single-file-bundle/go) and
178
178
  [`examples/single-file-bundle/python`](./examples/single-file-bundle/python)
179
- - a reference Node host and sync `sdn_host` bridge for the first hostcall
180
- surface
179
+ - reference Node and browser hosts plus the legacy sync `sdn_host` bridge for
180
+ sync-safe guest hostcalls
181
+
182
+ The current host/runtime contract is two-tiered:
183
+
184
+ - raw guest `sdn_host` imports stay sync-only
185
+ - generic async capabilities such as filesystem, network, IPFS, and protocol
186
+ adapters are exposed through `NodeHost`, `BrowserHost`, and the browser/module
187
+ harness APIs
181
188
 
182
189
  ## Runtime Targets
183
190
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "space-data-module-sdk",
3
- "version": "0.5.22",
3
+ "version": "0.6.0",
4
4
  "description": "Module SDK for building, validating, signing, and deploying WebAssembly modules on the Space Data Network.",
5
5
  "type": "module",
6
6
  "types": "./src/index.d.ts",
@@ -51,7 +51,7 @@
51
51
  ],
52
52
  "scripts": {
53
53
  "test": "node --test",
54
- "test:host-surfaces": "node --test test/node-host.test.js test/host-abi.test.js",
54
+ "test:host-surfaces": "node --test test/node-host.test.js test/host-abi.test.js test/browser-host.test.js test/browser-module-harness.test.js test/testing-harness.test.js",
55
55
  "test:runtime-matrix": "SPACE_DATA_MODULE_SDK_ENABLE_RUNTIME_MATRIX=1 node --test test/runtime-matrix.test.js",
56
56
  "test:stream-ingest": "node --test test/runtime-host-stream-ingest.test.js",
57
57
  "test:module-stream": "node --test test/module-flatbuffer-stream-pump.test.js",
package/src/host/abi.js CHANGED
@@ -7,7 +7,7 @@ export const DEFAULT_HOSTCALL_IMPORT_MODULE = "sdn_host";
7
7
  export const HOSTCALL_STATUS_OK = 0;
8
8
  export const HOSTCALL_STATUS_ERROR = 1;
9
9
 
10
- export const NodeHostSyncHostcallOperations = Object.freeze([
10
+ export const SyncHostcallOperations = Object.freeze([
11
11
  "host.runtimeTarget",
12
12
  "host.listCapabilities",
13
13
  "host.listSupportedCapabilities",
@@ -22,6 +22,7 @@ export const NodeHostSyncHostcallOperations = Object.freeze([
22
22
  "schedule.next",
23
23
  "filesystem.resolvePath",
24
24
  ]);
25
+ export const NodeHostSyncHostcallOperations = SyncHostcallOperations;
25
26
 
26
27
  function assertNonEmptyString(value, label) {
27
28
  const normalized = String(value ?? "").trim();
@@ -149,7 +150,7 @@ function encodeHostcallValue(value) {
149
150
  return value;
150
151
  }
151
152
 
152
- export function dispatchNodeHostSyncOperation(host, operation, params = null) {
153
+ export function dispatchHostSyncOperation(host, operation, params = null) {
153
154
  const normalized = assertNonEmptyString(operation, "Hostcall operation");
154
155
  switch (normalized) {
155
156
  case "host.runtimeTarget":
@@ -185,12 +186,39 @@ export function dispatchNodeHostSyncOperation(host, operation, params = null) {
185
186
  }
186
187
  }
187
188
 
189
+ export function dispatchNodeHostSyncOperation(host, operation, params = null) {
190
+ return dispatchHostSyncOperation(host, operation, params);
191
+ }
192
+
188
193
  export function createNodeHostSyncDispatcher(host) {
189
194
  if (!host || typeof host !== "object") {
190
195
  throw new TypeError("createNodeHostSyncDispatcher requires a host object.");
191
196
  }
192
197
  return (operation, params = null) =>
193
- dispatchNodeHostSyncOperation(host, operation, params);
198
+ dispatchHostSyncOperation(host, operation, params);
199
+ }
200
+
201
+ export function createHostSyncDispatcher(host) {
202
+ return createNodeHostSyncDispatcher(host);
203
+ }
204
+
205
+ export async function dispatchHostOperation(host, operation, params = null) {
206
+ if (!host || typeof host !== "object") {
207
+ throw new TypeError("dispatchHostOperation requires a host object.");
208
+ }
209
+ const normalized = assertNonEmptyString(operation, "Host operation");
210
+ if (typeof host.invoke === "function") {
211
+ return host.invoke(normalized, params);
212
+ }
213
+ return dispatchHostSyncOperation(host, normalized, params);
214
+ }
215
+
216
+ export function createAsyncHostDispatcher(host) {
217
+ if (!host || typeof host !== "object") {
218
+ throw new TypeError("createAsyncHostDispatcher requires a host object.");
219
+ }
220
+ return (operation, params = null) =>
221
+ dispatchHostOperation(host, operation, params);
194
222
  }
195
223
 
196
224
  export function createJsonHostcallBridge(options = {}) {
@@ -346,6 +346,10 @@ export function createBrowserEdgeShims(options = {}) {
346
346
  WebSocket: options.WebSocket ?? globalThis.WebSocket,
347
347
  crypto: options.crypto ?? globalThis.crypto,
348
348
  performance: options.performance ?? globalThis.performance,
349
+ network: options.network ?? null,
350
+ ipfs: options.ipfs ?? null,
351
+ protocolHandle: options.protocolHandle ?? null,
352
+ protocolDial: options.protocolDial ?? null,
349
353
  filesystem:
350
354
  options.filesystem ??
351
355
  createMemoryFilesystemEdgeShim({
@@ -20,7 +20,11 @@ export const BrowserHostSupportedCapabilities = Object.freeze([
20
20
  "schedule_cron",
21
21
  "http",
22
22
  "websocket",
23
+ "network",
23
24
  "filesystem",
25
+ "ipfs",
26
+ "protocol_handle",
27
+ "protocol_dial",
24
28
  "context_read",
25
29
  "context_write",
26
30
  "crypto_hash",
@@ -39,11 +43,13 @@ export const BrowserHostSupportedOperations = Object.freeze([
39
43
  "clock.monotonicNow",
40
44
  "clock.nowIso",
41
45
  "random.bytes",
46
+ "timers.delay",
42
47
  "schedule.parse",
43
48
  "schedule.matches",
44
49
  "schedule.next",
45
50
  "http.request",
46
51
  "websocket.exchange",
52
+ "network.request",
47
53
  "filesystem.resolvePath",
48
54
  "filesystem.readFile",
49
55
  "filesystem.writeFile",
@@ -53,6 +59,10 @@ export const BrowserHostSupportedOperations = Object.freeze([
53
59
  "filesystem.readdir",
54
60
  "filesystem.stat",
55
61
  "filesystem.rename",
62
+ "ipfs.invoke",
63
+ "protocol_handle.register",
64
+ "protocol_handle.unregister",
65
+ "protocol_dial.dial",
56
66
  "context.get",
57
67
  "context.set",
58
68
  "context.delete",
@@ -73,6 +83,46 @@ export class BrowserHostCapabilityError extends Error {
73
83
  }
74
84
  }
75
85
 
86
+ function normalizeBrowserNetworkTransport(params = {}) {
87
+ const explicit = String(
88
+ params.transport ?? params.kind ?? params.request?.transport ?? "",
89
+ )
90
+ .trim()
91
+ .toLowerCase();
92
+ if (explicit) {
93
+ return explicit;
94
+ }
95
+ const candidateUrl = params.url ?? params.request?.url ?? null;
96
+ if (candidateUrl) {
97
+ const protocol = new URL(candidateUrl, "https://browser-host.invalid")
98
+ .protocol.toLowerCase();
99
+ if (protocol === "http:" || protocol === "https:") {
100
+ return "http";
101
+ }
102
+ if (protocol === "ws:" || protocol === "wss:") {
103
+ return "websocket";
104
+ }
105
+ }
106
+ throw new Error(
107
+ 'network.request requires a transport value such as "http" or "websocket".',
108
+ );
109
+ }
110
+
111
+ async function invokeAdapterMethod(adapter, methodName, params, label) {
112
+ if (!adapter || typeof adapter !== "object") {
113
+ throw new Error(`${label} adapter is not configured for this host.`);
114
+ }
115
+ if (typeof adapter[methodName] === "function") {
116
+ return adapter[methodName](params);
117
+ }
118
+ if (typeof adapter.invoke === "function") {
119
+ return adapter.invoke(methodName, params);
120
+ }
121
+ throw new Error(
122
+ `${label} adapter does not implement "${methodName}" or invoke().`,
123
+ );
124
+ }
125
+
76
126
  export class BrowserHost {
77
127
  constructor(options = {}) {
78
128
  this.runtimeTarget = RuntimeTarget.BROWSER;
@@ -86,6 +136,11 @@ export class BrowserHost {
86
136
  WebSocket: options.WebSocket ?? options.edgeShims?.WebSocket,
87
137
  crypto: options.crypto ?? options.edgeShims?.crypto,
88
138
  performance: options.performance ?? options.edgeShims?.performance,
139
+ network: options.network ?? options.edgeShims?.network,
140
+ ipfs: options.ipfs ?? options.edgeShims?.ipfs,
141
+ protocolHandle:
142
+ options.protocolHandle ?? options.edgeShims?.protocolHandle,
143
+ protocolDial: options.protocolDial ?? options.edgeShims?.protocolDial,
89
144
  filesystem: options.filesystem ?? options.edgeShims?.filesystem,
90
145
  filesystemRoot: options.filesystemRoot ?? options.edgeShims?.filesystemRoot,
91
146
  });
@@ -97,6 +152,10 @@ export class BrowserHost {
97
152
  const fetchImpl = edgeShims.fetch;
98
153
  const WebSocketImpl = edgeShims.WebSocket;
99
154
  const filesystem = edgeShims.filesystem;
155
+ const networkAdapter = edgeShims.network;
156
+ const ipfsAdapter = edgeShims.ipfs;
157
+ const protocolHandleAdapter = edgeShims.protocolHandle;
158
+ const protocolDialAdapter = edgeShims.protocolDial;
100
159
 
101
160
  this._grantedCapabilities = granted;
102
161
  this._contextStore = options.contextStore ?? new Map();
@@ -239,6 +298,73 @@ export class BrowserHost {
239
298
  },
240
299
  });
241
300
 
301
+ this.network = Object.freeze({
302
+ request: async (params = {}) => {
303
+ this.#assertCapability("network", "network.request");
304
+ const transport = normalizeBrowserNetworkTransport(params);
305
+ const request = params.request ?? params;
306
+ if (transport === "http") {
307
+ return this.http.request(request);
308
+ }
309
+ if (transport === "websocket") {
310
+ return this.websocket.exchange(request);
311
+ }
312
+ if (networkAdapter) {
313
+ return invokeAdapterMethod(networkAdapter, "request", {
314
+ ...request,
315
+ transport,
316
+ }, "network");
317
+ }
318
+ throw new Error(
319
+ `Browser host does not support network transport "${transport}".`,
320
+ );
321
+ },
322
+ });
323
+
324
+ this.ipfs = Object.freeze({
325
+ invoke: async (params = {}) => {
326
+ this.#assertCapability("ipfs", "ipfs.invoke");
327
+ const operation = String(params.operation ?? "invoke").trim();
328
+ if (!operation) {
329
+ throw new Error("ipfs.invoke requires a non-empty operation.");
330
+ }
331
+ return invokeAdapterMethod(ipfsAdapter, operation, params, "ipfs");
332
+ },
333
+ });
334
+
335
+ this.protocolHandle = Object.freeze({
336
+ register: async (params = {}) => {
337
+ this.#assertCapability("protocol_handle", "protocol_handle.register");
338
+ return invokeAdapterMethod(
339
+ protocolHandleAdapter,
340
+ "register",
341
+ params,
342
+ "protocol_handle",
343
+ );
344
+ },
345
+ unregister: async (params = {}) => {
346
+ this.#assertCapability("protocol_handle", "protocol_handle.unregister");
347
+ return invokeAdapterMethod(
348
+ protocolHandleAdapter,
349
+ "unregister",
350
+ params,
351
+ "protocol_handle",
352
+ );
353
+ },
354
+ });
355
+
356
+ this.protocolDial = Object.freeze({
357
+ dial: async (params = {}) => {
358
+ this.#assertCapability("protocol_dial", "protocol_dial.dial");
359
+ return invokeAdapterMethod(
360
+ protocolDialAdapter,
361
+ "dial",
362
+ params,
363
+ "protocol_dial",
364
+ );
365
+ },
366
+ });
367
+
242
368
  this.context = Object.freeze({
243
369
  get: (scope, key) => {
244
370
  this.#assertCapability("context_read", "context.get");
@@ -378,17 +504,125 @@ export class BrowserHost {
378
504
  }
379
505
 
380
506
  hasCapability(capability) {
381
- return this._grantedCapabilities.has(capability);
507
+ const normalized = String(capability ?? "").trim();
508
+ return (
509
+ this._grantedCapabilities.has(normalized) ||
510
+ (this._grantedCapabilities.has("network") &&
511
+ ["http", "websocket"].includes(normalized))
512
+ );
382
513
  }
383
514
 
384
515
  assertCapability(capability, operation) {
385
516
  this.#assertCapability(capability, operation);
386
517
  }
387
518
 
519
+ async invoke(operation, params = {}) {
520
+ const normalized = String(operation ?? "").trim();
521
+ switch (normalized) {
522
+ case "host.runtimeTarget":
523
+ return this.runtimeTarget;
524
+ case "host.listCapabilities":
525
+ return this.listCapabilities();
526
+ case "host.listSupportedCapabilities":
527
+ return this.listSupportedCapabilities();
528
+ case "host.listOperations":
529
+ return this.listOperations();
530
+ case "host.hasCapability":
531
+ return this.hasCapability(params.capability);
532
+ case "clock.now":
533
+ return this.clock.now();
534
+ case "clock.monotonicNow":
535
+ return this.clock.monotonicNow();
536
+ case "clock.nowIso":
537
+ return this.clock.nowIso();
538
+ case "random.bytes":
539
+ return this.random.bytes(params.length);
540
+ case "timers.delay":
541
+ return this.timers.delay(params.ms ?? params.delayMs ?? 0);
542
+ case "schedule.parse":
543
+ return this.schedule.parse(params.expression);
544
+ case "schedule.matches":
545
+ return this.schedule.matches(params.expression, params.date);
546
+ case "schedule.next":
547
+ return this.schedule.next(params.expression, params.from);
548
+ case "http.request":
549
+ return this.http.request(params);
550
+ case "websocket.exchange":
551
+ return this.websocket.exchange(params);
552
+ case "network.request":
553
+ return this.network.request(params);
554
+ case "filesystem.resolvePath":
555
+ return this.filesystem.resolvePath(params.path);
556
+ case "filesystem.readFile":
557
+ return this.filesystem.readFile(params.path, {
558
+ encoding: params.encoding,
559
+ });
560
+ case "filesystem.writeFile":
561
+ return this.filesystem.writeFile(params.path, params.value, {
562
+ encoding: params.encoding,
563
+ });
564
+ case "filesystem.appendFile":
565
+ return this.filesystem.appendFile(params.path, params.value, {
566
+ encoding: params.encoding,
567
+ });
568
+ case "filesystem.deleteFile":
569
+ return this.filesystem.deleteFile(params.path);
570
+ case "filesystem.mkdir":
571
+ return this.filesystem.mkdir(params.path, {
572
+ recursive: params.recursive,
573
+ });
574
+ case "filesystem.readdir":
575
+ return this.filesystem.readdir(params.path);
576
+ case "filesystem.stat":
577
+ return this.filesystem.stat(params.path);
578
+ case "filesystem.rename":
579
+ return this.filesystem.rename(params.fromPath, params.toPath);
580
+ case "ipfs.invoke":
581
+ return this.ipfs.invoke(params);
582
+ case "protocol_handle.register":
583
+ return this.protocolHandle.register(params);
584
+ case "protocol_handle.unregister":
585
+ return this.protocolHandle.unregister(params);
586
+ case "protocol_dial.dial":
587
+ return this.protocolDial.dial(params);
588
+ case "context.get":
589
+ return this.context.get(params.scope, params.key);
590
+ case "context.set":
591
+ return this.context.set(params.scope, params.key, params.value);
592
+ case "context.delete":
593
+ return this.context.delete(params.scope, params.key);
594
+ case "context.listKeys":
595
+ return this.context.listKeys(params.scope);
596
+ case "context.listScopes":
597
+ return this.context.listScopes();
598
+ case "crypto.sha256":
599
+ return this.crypto.sha256(params.value ?? params.bytes);
600
+ case "crypto.sha512":
601
+ return this.crypto.sha512(params.value ?? params.bytes);
602
+ case "crypto.aesGcmEncrypt":
603
+ return this.crypto.aesGcmEncrypt(params);
604
+ case "crypto.aesGcmDecrypt":
605
+ return this.crypto.aesGcmDecrypt(params);
606
+ default:
607
+ throw new Error(`Unknown browser host operation "${normalized}".`);
608
+ }
609
+ }
610
+
388
611
  // --- Private ---
389
612
 
390
613
  #assertCapability(capability, operation) {
391
- if (!this._grantedCapabilities.has(capability)) {
614
+ const normalized = String(capability ?? "").trim();
615
+ const networkBackedCapabilities = new Set([
616
+ "http",
617
+ "websocket",
618
+ ]);
619
+ if (
620
+ !this._grantedCapabilities.has(normalized) &&
621
+ !(
622
+ this._grantedCapabilities.has("network") &&
623
+ networkBackedCapabilities.has(normalized)
624
+ )
625
+ ) {
392
626
  throw new BrowserHostCapabilityError(capability, operation);
393
627
  }
394
628
  }
@@ -13,11 +13,25 @@ import {
13
13
  createBrowserModuleHarness,
14
14
  detectArtifactProfile,
15
15
  } from "../testing/browserModuleHarness.js";
16
+ import { dispatchHostOperation } from "./abi.js";
16
17
 
17
18
  const isBrowser =
18
19
  typeof globalThis.window !== "undefined" &&
19
20
  typeof globalThis.document !== "undefined";
20
21
 
22
+ function attachHostDispatch(harness, host) {
23
+ if (!host || typeof host !== "object") {
24
+ return harness;
25
+ }
26
+ return {
27
+ ...harness,
28
+ host,
29
+ async callHost(operation, params = {}) {
30
+ return dispatchHostOperation(host, operation, params);
31
+ },
32
+ };
33
+ }
34
+
21
35
  async function createWasmEdgeCommandHarness(options = {}) {
22
36
  const [
23
37
  { spawn },
@@ -175,14 +189,18 @@ export async function loadModule(options = {}) {
175
189
  (inspection.profile === "standalone" || inspection.profile === "sdn-abi") &&
176
190
  inspection.exports.includes("_start")
177
191
  ) {
178
- return createWasmEdgeCommandHarness(options);
192
+ return attachHostDispatch(
193
+ await createWasmEdgeCommandHarness(options),
194
+ options.host ?? null,
195
+ );
179
196
  }
180
197
  }
181
198
 
182
- return createModuleHarness({
183
- runtime: {
184
- kind: "wasmedge",
185
- wasmPath: source,
199
+ return attachHostDispatch(
200
+ await createModuleHarness({
201
+ runtime: {
202
+ kind: "wasmedge",
203
+ wasmPath: source,
186
204
  wasmEdgeBinary: options.wasmEdgeBinary,
187
205
  wasmEdgeRunnerBinary: options.wasmEdgeRunnerBinary,
188
206
  enableThreads: options.enableThreads,
@@ -191,23 +209,28 @@ export async function loadModule(options = {}) {
191
209
  hostProfile: options.hostProfile,
192
210
  modules: options.modules,
193
211
  defaultModuleId: options.defaultModuleId,
194
- metadata: options.metadata,
195
- },
196
- });
212
+ metadata: options.metadata,
213
+ },
214
+ }),
215
+ options.host ?? null,
216
+ );
197
217
  }
198
218
 
199
- return createModuleHarness({
200
- runtime: {
201
- kind: runtimeKind,
202
- command: options.command ?? runtimeKind,
203
- args: options.args ?? [source],
204
- env: options.env,
205
- cwd: options.cwd,
206
- hostProfile: options.hostProfile,
207
- modules: options.modules,
208
- defaultModuleId: options.defaultModuleId,
209
- },
210
- });
219
+ return attachHostDispatch(
220
+ await createModuleHarness({
221
+ runtime: {
222
+ kind: runtimeKind,
223
+ command: options.command ?? runtimeKind,
224
+ args: options.args ?? [source],
225
+ env: options.env,
226
+ cwd: options.cwd,
227
+ hostProfile: options.hostProfile,
228
+ modules: options.modules,
229
+ defaultModuleId: options.defaultModuleId,
230
+ },
231
+ }),
232
+ options.host ?? null,
233
+ );
211
234
  }
212
235
 
213
236
  /**
@@ -46,10 +46,14 @@ export const NodeHostSupportedCapabilities = Object.freeze([
46
46
  "http",
47
47
  "websocket",
48
48
  "mqtt",
49
+ "network",
49
50
  "filesystem",
50
51
  "tcp",
51
52
  "udp",
52
53
  "tls",
54
+ "ipfs",
55
+ "protocol_handle",
56
+ "protocol_dial",
53
57
  "context_read",
54
58
  "context_write",
55
59
  "crypto_hash",
@@ -75,6 +79,7 @@ export const NodeHostSupportedOperations = Object.freeze([
75
79
  "websocket.exchange",
76
80
  "mqtt.publish",
77
81
  "mqtt.subscribeOnce",
82
+ "network.request",
78
83
  "filesystem.resolvePath",
79
84
  "filesystem.readFile",
80
85
  "filesystem.writeFile",
@@ -87,6 +92,10 @@ export const NodeHostSupportedOperations = Object.freeze([
87
92
  "tcp.request",
88
93
  "udp.request",
89
94
  "tls.request",
95
+ "ipfs.invoke",
96
+ "protocol_handle.register",
97
+ "protocol_handle.unregister",
98
+ "protocol_dial.dial",
90
99
  "exec.execFile",
91
100
  "context.get",
92
101
  "context.set",
@@ -229,6 +238,45 @@ function normalizeAllowedPorts(ports, label, { allowZero = false } = {}) {
229
238
  return normalized;
230
239
  }
231
240
 
241
+ function normalizeNetworkTransport(params = {}) {
242
+ const explicit = String(
243
+ params.transport ?? params.kind ?? params.request?.transport ?? "",
244
+ )
245
+ .trim()
246
+ .toLowerCase();
247
+ if (explicit) {
248
+ return explicit;
249
+ }
250
+ const candidateUrl = params.url ?? params.request?.url ?? null;
251
+ if (candidateUrl) {
252
+ const protocol = new URL(candidateUrl).protocol.toLowerCase();
253
+ if (protocol === "http:" || protocol === "https:") {
254
+ return "http";
255
+ }
256
+ if (protocol === "ws:" || protocol === "wss:") {
257
+ return "websocket";
258
+ }
259
+ }
260
+ throw new Error(
261
+ 'network.request requires a transport value such as "http", "tcp", or "websocket".',
262
+ );
263
+ }
264
+
265
+ async function invokeAdapterMethod(adapter, methodName, params, label) {
266
+ if (!adapter || typeof adapter !== "object") {
267
+ throw new Error(`${label} adapter is not configured for this host.`);
268
+ }
269
+ if (typeof adapter[methodName] === "function") {
270
+ return adapter[methodName](params);
271
+ }
272
+ if (typeof adapter.invoke === "function") {
273
+ return adapter.invoke(methodName, params);
274
+ }
275
+ throw new Error(
276
+ `${label} adapter does not implement "${methodName}" or invoke().`,
277
+ );
278
+ }
279
+
232
280
  function normalizeFilesystemRoot(rootPath) {
233
281
  return path.resolve(String(rootPath ?? process.cwd()));
234
282
  }
@@ -1397,6 +1445,10 @@ export class NodeHost {
1397
1445
  );
1398
1446
  this.fetch = options.fetch ?? globalThis.fetch;
1399
1447
  this.WebSocket = options.WebSocket ?? globalThis.WebSocket;
1448
+ this._networkAdapter = options.network ?? null;
1449
+ this._ipfsAdapter = options.ipfs ?? null;
1450
+ this._protocolHandleAdapter = options.protocolHandle ?? null;
1451
+ this._protocolDialAdapter = options.protocolDial ?? null;
1400
1452
  if (typeof this.fetch !== "function") {
1401
1453
  throw new TypeError(
1402
1454
  "A fetch implementation is required to create a Node host.",
@@ -1550,6 +1602,40 @@ export class NodeHost {
1550
1602
  }),
1551
1603
  });
1552
1604
 
1605
+ this.network = Object.freeze({
1606
+ request: async (params = {}) =>
1607
+ this.#withCapability("network", "network.request", async () => {
1608
+ const transport = normalizeNetworkTransport(params);
1609
+ const request = params.request ?? params;
1610
+ switch (transport) {
1611
+ case "http":
1612
+ return this.http.request(request);
1613
+ case "websocket":
1614
+ return this.websocket.exchange(request);
1615
+ case "mqtt":
1616
+ return request.subscribe === true
1617
+ ? this.mqtt.subscribeOnce(request)
1618
+ : this.mqtt.publish(request);
1619
+ case "tcp":
1620
+ return this.tcp.request(request);
1621
+ case "udp":
1622
+ return this.udp.request(request);
1623
+ case "tls":
1624
+ return this.tls.request(request);
1625
+ default:
1626
+ if (this._networkAdapter) {
1627
+ return invokeAdapterMethod(this._networkAdapter, "request", {
1628
+ ...request,
1629
+ transport,
1630
+ }, "network");
1631
+ }
1632
+ throw new Error(
1633
+ `Node host does not support network transport "${transport}".`,
1634
+ );
1635
+ }
1636
+ }),
1637
+ });
1638
+
1553
1639
  this.mqtt = Object.freeze({
1554
1640
  publish: async (mqttOptions = {}) =>
1555
1641
  this.#withCapability("mqtt", "mqtt.publish", async () => {
@@ -1825,6 +1911,56 @@ export class NodeHost {
1825
1911
  this._contextStore.listScopes(),
1826
1912
  ),
1827
1913
  });
1914
+
1915
+ this.ipfs = Object.freeze({
1916
+ invoke: async (params = {}) =>
1917
+ this.#withCapability("ipfs", "ipfs.invoke", async () => {
1918
+ const operation = String(params.operation ?? "invoke").trim();
1919
+ if (!operation) {
1920
+ throw new Error("ipfs.invoke requires a non-empty operation.");
1921
+ }
1922
+ return invokeAdapterMethod(this._ipfsAdapter, operation, params, "ipfs");
1923
+ }),
1924
+ });
1925
+
1926
+ this.protocolHandle = Object.freeze({
1927
+ register: async (params = {}) =>
1928
+ this.#withCapability(
1929
+ "protocol_handle",
1930
+ "protocol_handle.register",
1931
+ async () =>
1932
+ invokeAdapterMethod(
1933
+ this._protocolHandleAdapter,
1934
+ "register",
1935
+ params,
1936
+ "protocol_handle",
1937
+ ),
1938
+ ),
1939
+ unregister: async (params = {}) =>
1940
+ this.#withCapability(
1941
+ "protocol_handle",
1942
+ "protocol_handle.unregister",
1943
+ async () =>
1944
+ invokeAdapterMethod(
1945
+ this._protocolHandleAdapter,
1946
+ "unregister",
1947
+ params,
1948
+ "protocol_handle",
1949
+ ),
1950
+ ),
1951
+ });
1952
+
1953
+ this.protocolDial = Object.freeze({
1954
+ dial: async (params = {}) =>
1955
+ this.#withCapability("protocol_dial", "protocol_dial.dial", async () =>
1956
+ invokeAdapterMethod(
1957
+ this._protocolDialAdapter,
1958
+ "dial",
1959
+ params,
1960
+ "protocol_dial",
1961
+ ),
1962
+ ),
1963
+ });
1828
1964
  }
1829
1965
 
1830
1966
  listCapabilities() {
@@ -1840,11 +1976,26 @@ export class NodeHost {
1840
1976
  }
1841
1977
 
1842
1978
  hasCapability(capability) {
1843
- return this._grantedCapabilities.has(String(capability ?? "").trim());
1979
+ const normalized = String(capability ?? "").trim();
1980
+ return (
1981
+ this._grantedCapabilities.has(normalized) ||
1982
+ (this._grantedCapabilities.has("network") &&
1983
+ ["http", "websocket", "mqtt", "tcp", "udp", "tls"].includes(
1984
+ normalized,
1985
+ ))
1986
+ );
1844
1987
  }
1845
1988
 
1846
1989
  assertCapability(capability, operation = null) {
1847
1990
  const normalized = assertNonEmptyString(capability, "Capability id");
1991
+ const networkBackedCapabilities = new Set([
1992
+ "http",
1993
+ "websocket",
1994
+ "mqtt",
1995
+ "tcp",
1996
+ "udp",
1997
+ "tls",
1998
+ ]);
1848
1999
  if (!this._supportedCapabilities.has(normalized)) {
1849
2000
  throw new HostCapabilityError(
1850
2001
  `Capability "${normalized}" is not supported by the reference Node host.`,
@@ -1855,7 +2006,13 @@ export class NodeHost {
1855
2006
  },
1856
2007
  );
1857
2008
  }
1858
- if (!this._grantedCapabilities.has(normalized)) {
2009
+ if (
2010
+ !this._grantedCapabilities.has(normalized) &&
2011
+ !(
2012
+ this._grantedCapabilities.has("network") &&
2013
+ networkBackedCapabilities.has(normalized)
2014
+ )
2015
+ ) {
1859
2016
  throw new HostCapabilityError(
1860
2017
  `Capability "${normalized}" is not granted for this Node host.`,
1861
2018
  {
@@ -1871,6 +2028,16 @@ export class NodeHost {
1871
2028
  async invoke(operation, params = {}) {
1872
2029
  const normalized = assertNonEmptyString(operation, "Host operation");
1873
2030
  switch (normalized) {
2031
+ case "host.runtimeTarget":
2032
+ return this.runtimeTarget;
2033
+ case "host.listCapabilities":
2034
+ return this.listCapabilities();
2035
+ case "host.listSupportedCapabilities":
2036
+ return this.listSupportedCapabilities();
2037
+ case "host.listOperations":
2038
+ return this.listOperations();
2039
+ case "host.hasCapability":
2040
+ return this.hasCapability(params.capability);
1874
2041
  case "clock.now":
1875
2042
  return this.clock.now();
1876
2043
  case "clock.monotonicNow":
@@ -1897,6 +2064,8 @@ export class NodeHost {
1897
2064
  return this.mqtt.publish(params);
1898
2065
  case "mqtt.subscribeOnce":
1899
2066
  return this.mqtt.subscribeOnce(params);
2067
+ case "network.request":
2068
+ return this.network.request(params);
1900
2069
  case "filesystem.resolvePath":
1901
2070
  return this.filesystem.resolvePath(params.path);
1902
2071
  case "filesystem.readFile":
@@ -1929,6 +2098,14 @@ export class NodeHost {
1929
2098
  return this.udp.request(params);
1930
2099
  case "tls.request":
1931
2100
  return this.tls.request(params);
2101
+ case "ipfs.invoke":
2102
+ return this.ipfs.invoke(params);
2103
+ case "protocol_handle.register":
2104
+ return this.protocolHandle.register(params);
2105
+ case "protocol_handle.unregister":
2106
+ return this.protocolHandle.unregister(params);
2107
+ case "protocol_dial.dial":
2108
+ return this.protocolDial.dial(params);
1932
2109
  case "exec.execFile":
1933
2110
  return this.exec.execFile(params);
1934
2111
  case "context.get":
@@ -3,15 +3,71 @@ import { createFlatBufferStreamIngestor } from "./flatbufferStreamIngestor.js";
3
3
  import { createRuntimeRegionStore } from "./runtimeRegionStore.js";
4
4
  import { createModuleRegistry } from "./moduleRegistry.js";
5
5
 
6
+ function normalizeCapabilityId(capability) {
7
+ const normalized = String(capability ?? "").trim();
8
+ if (!normalized) {
9
+ throw new TypeError("Capability id is required.");
10
+ }
11
+ return normalized;
12
+ }
13
+
14
+ function createCapabilityRegistry(capabilities = {}) {
15
+ const entries =
16
+ capabilities instanceof Map
17
+ ? capabilities.entries()
18
+ : Object.entries(capabilities ?? {});
19
+ const registry = new Map();
20
+ for (const [capability, adapter] of entries) {
21
+ registry.set(normalizeCapabilityId(capability), adapter);
22
+ }
23
+ return registry;
24
+ }
25
+
6
26
  export function createRuntimeHost(options = {}) {
7
27
  const rows = options.rows ?? createFlatSqlRuntimeStore();
8
28
  const regions = options.regions ?? createRuntimeRegionStore();
9
29
  const moduleRegistry = options.moduleRegistry ?? createModuleRegistry();
30
+ const capabilities = createCapabilityRegistry(options.capabilities);
10
31
 
11
32
  return {
12
33
  rows,
13
34
  regions,
14
35
  moduleRegistry,
36
+ listCapabilities() {
37
+ return Array.from(capabilities.keys()).sort();
38
+ },
39
+ getCapability(capability) {
40
+ return capabilities.get(normalizeCapabilityId(capability)) ?? null;
41
+ },
42
+ registerCapability(capability, adapter) {
43
+ const normalized = normalizeCapabilityId(capability);
44
+ capabilities.set(normalized, adapter);
45
+ return adapter;
46
+ },
47
+ unregisterCapability(capability) {
48
+ return capabilities.delete(normalizeCapabilityId(capability));
49
+ },
50
+ async invokeCapability(operation, params = {}) {
51
+ const normalized = normalizeCapabilityId(operation);
52
+ const separator = normalized.indexOf(".");
53
+ const capabilityId =
54
+ separator >= 0 ? normalized.slice(0, separator) : normalized;
55
+ const methodId =
56
+ separator >= 0 ? normalized.slice(separator + 1) : "invoke";
57
+ const adapter = capabilities.get(capabilityId);
58
+ if (!adapter || typeof adapter !== "object") {
59
+ throw new Error(`Runtime host capability "${capabilityId}" is not registered.`);
60
+ }
61
+ if (typeof adapter[methodId] === "function") {
62
+ return adapter[methodId](params);
63
+ }
64
+ if (typeof adapter.invoke === "function") {
65
+ return adapter.invoke(methodId, params);
66
+ }
67
+ throw new Error(
68
+ `Runtime host capability "${capabilityId}" does not implement "${methodId}" or invoke().`,
69
+ );
70
+ },
15
71
  };
16
72
  }
17
73
 
@@ -16,7 +16,8 @@ import { createBrowserWasiShim, WasiExitError } from "../host/wasiShim.js";
16
16
  import { createBrowserHost } from "../host/browserHost.js";
17
17
  import {
18
18
  createJsonHostcallBridge,
19
- createNodeHostSyncDispatcher,
19
+ createHostSyncDispatcher,
20
+ dispatchHostOperation,
20
21
  DEFAULT_HOSTCALL_IMPORT_MODULE,
21
22
  } from "../host/abi.js";
22
23
  import {
@@ -90,7 +91,7 @@ async function instantiateBrowserModule(options = {}) {
90
91
  let instance = null;
91
92
  let bridge = null;
92
93
  if (needsHostBridge) {
93
- const dispatch = createNodeHostSyncDispatcher(options.host);
94
+ const dispatch = createHostSyncDispatcher(options.host);
94
95
  bridge = createJsonHostcallBridge({
95
96
  dispatch,
96
97
  getMemory: () => instance.exports.memory,
@@ -238,6 +239,10 @@ export async function createBrowserModuleHarness(options = {}) {
238
239
  return decodePluginInvokeResponse(responseBytes);
239
240
  }
240
241
 
242
+ async function callHost(operation, params = {}) {
243
+ return dispatchHostOperation(host, operation, params);
244
+ }
245
+
241
246
  function readManifest() {
242
247
  const getBytesExport =
243
248
  instance.exports[DefaultManifestExports.pluginBytesSymbol];
@@ -267,6 +272,7 @@ export async function createBrowserModuleHarness(options = {}) {
267
272
  host,
268
273
  bridge,
269
274
  wasi,
275
+ callHost,
270
276
  invoke,
271
277
  invokeRaw,
272
278
  readManifest,
@@ -120,7 +120,8 @@ const CapabilitySurfaceMatrix = Object.freeze({
120
120
  syncHostcall: false,
121
121
  nodeHostApi: true,
122
122
  notes: [
123
- "The coarse network capability maps to host-side HTTP/TCP/UDP/TLS/WebSocket services today.",
123
+ "The coarse network capability maps to async host-side HTTP/TCP/UDP/TLS/WebSocket services.",
124
+ "Browser harnesses can await the same capability surface through BrowserHost.invoke().",
124
125
  "Pure WASI guests cannot reach that surface through the current sync hostcall ABI.",
125
126
  "WasmEdge provides non-blocking socket-oriented extensions that can serve as the standard server-side max-WASI target.",
126
127
  ],
@@ -198,6 +199,42 @@ const CapabilitySurfaceMatrix = Object.freeze({
198
199
  "WasmEdge TLS support provides a practical no-wrapper server-side target for guest HTTPS/TLS logic.",
199
200
  ],
200
201
  }),
202
+ ipfs: Object.freeze({
203
+ capability: "ipfs",
204
+ wasi: false,
205
+ standaloneWasi: false,
206
+ wasmedge: false,
207
+ syncHostcall: false,
208
+ nodeHostApi: true,
209
+ notes: [
210
+ "IPFS access is available through async host adapters and browser/module harness dispatch.",
211
+ "Pure WASM guests cannot reach that surface through the current sync hostcall ABI.",
212
+ ],
213
+ }),
214
+ protocol_handle: Object.freeze({
215
+ capability: "protocol_handle",
216
+ wasi: false,
217
+ standaloneWasi: false,
218
+ wasmedge: false,
219
+ syncHostcall: false,
220
+ nodeHostApi: true,
221
+ notes: [
222
+ "Protocol registration is available through async host adapters and browser/module harness dispatch.",
223
+ "Pure WASM guests cannot reach that surface through the current sync hostcall ABI.",
224
+ ],
225
+ }),
226
+ protocol_dial: Object.freeze({
227
+ capability: "protocol_dial",
228
+ wasi: false,
229
+ standaloneWasi: false,
230
+ wasmedge: false,
231
+ syncHostcall: false,
232
+ nodeHostApi: true,
233
+ notes: [
234
+ "Protocol dialing is available through async host adapters and browser/module harness dispatch.",
235
+ "Pure WASM guests cannot reach that surface through the current sync hostcall ABI.",
236
+ ],
237
+ }),
201
238
  context_read: Object.freeze({
202
239
  capability: "context_read",
203
240
  wasi: false,