From 07b6aa72ff9e315842764b7d15b45325fe434dee Mon Sep 17 00:00:00 2001 From: Chris Dickinson Date: Mon, 27 Nov 2023 14:15:51 -0800 Subject: [PATCH] feat: accept WebAssembly.Module, Response inputs `createPlugin` now accepts two additional manifest types (`response` and `module`) as well as `Response` and `WebAssembly.Module`. There are four goals here: 1. Allow us to target the Cloudflare Workers platform. CF Workers only support loading Wasm via `import` statements; these resolve to `WebAssembly.Module` objects, which means we need to allow users to pass `Module`s in addition to our other types. 2. Play nicely with V8's [Wasm caching][1]; in particular V8 will use metadata from the `Response` to build a key for caching the results of Wasm compilation. 3. This sets us up to implement [Wasm linking][2] by allowing us to introspect plugin modules imports and exports before instantiation. 4. And finally, resolving to modules instead of arraybuffers allows us to add [hooks for observe-sdk][3] (especially in advance of adding [thread pooling][4]). Because Bun lacks support for `WebAssembly.compileStreaming` and `Response.clone()`, we provide an alternate implementation for converting a response to a module and its metadata. One caveat is that there's no way to get the source bytes of a `WebAssembly.Module`, so `{module}` cannot be used with `{hash}` in a `Manifest`. Fixes https://github.com/extism/js-sdk/issues/9 [1]: https://v8.dev/blog/wasm-code-caching#stream [2]: https://github.com/extism/js-sdk/issues/29 [3]: https://github.com/extism/js-sdk/issues/3 [4]: https://github.com/extism/js-sdk/issues/31 --- deno.json | 1 + justfile | 4 ++ src/background-plugin.ts | 4 +- src/foreground-plugin.ts | 48 ++++++++--------- src/interfaces.ts | 33 ++++++++++-- src/manifest.ts | 72 ++++++++++++++++++------- src/mod.test.ts | 30 +++++++++++ src/mod.ts | 8 +-- src/polyfills/bun-response-to-module.ts | 23 ++++++++ src/polyfills/response-to-module.ts | 26 +++++++++ src/worker.ts | 2 +- types/js-sdk/index.d.ts | 4 ++ 12 files changed, 201 insertions(+), 54 deletions(-) create mode 100644 src/polyfills/bun-response-to-module.ts create mode 100644 src/polyfills/response-to-module.ts diff --git a/deno.json b/deno.json index 8e9f546..6ce97fd 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,7 @@ { "imports": { "js-sdk:worker-url": "./src/worker-url.ts", + "js-sdk:response-to-module": "./src/polyfills/response-to-module.ts", "js-sdk:minimatch": "./src/polyfills/deno-minimatch.ts", "js-sdk:capabilities": "./src/polyfills/deno-capabilities.ts", "js-sdk:wasi": "./src/polyfills/deno-wasi.ts", diff --git a/justfile b/justfile index 4eb9c8b..5bc3030 100644 --- a/justfile +++ b/justfile @@ -151,6 +151,7 @@ build_node_cjs out='cjs' args='[]': "minify": false, "alias": { "js-sdk:capabilities": "./src/polyfills/node-capabilities.ts", + "js-sdk:response-to-module": "./src/polyfills/response-to-module.ts", "js-sdk:minimatch": "./src/polyfills/node-minimatch.ts", "js-sdk:worker-url": "./dist/worker/node/worker-url.ts", "js-sdk:fs": "node:fs/promises", @@ -182,6 +183,7 @@ build_node_esm out='esm' args='[]': "minify": false, "alias": { "js-sdk:capabilities": "./src/polyfills/node-capabilities.ts", + "js-sdk:response-to-module": "./src/polyfills/response-to-module.ts", "js-sdk:minimatch": "./src/polyfills/node-minimatch.ts", "js-sdk:worker-url": "./dist/worker/node/worker-url.ts", "js-sdk:fs": "node:fs/promises", @@ -202,6 +204,7 @@ build_bun out='bun' args='[]': "minify": false, "alias": { "js-sdk:worker-url": "./src/polyfills/bun-worker-url.ts", + "js-sdk:response-to-module": "./src/polyfills/bun-response-to-module.ts", "js-sdk:minimatch": "./src/polyfills/node-minimatch.ts", "js-sdk:capabilities": "./src/polyfills/bun-capabilities.ts", "js-sdk:fs": "node:fs/promises", @@ -222,6 +225,7 @@ build_browser out='browser' args='[]': "format": "esm", "alias": { "js-sdk:capabilities": "./src/polyfills/browser-capabilities.ts", + "js-sdk:response-to-module": "./src/polyfills/response-to-module.ts", "js-sdk:minimatch": "./src/polyfills/node-minimatch.ts", "node:worker_threads": "./src/polyfills/host-node-worker_threads.ts", "js-sdk:fs": "./src/polyfills/browser-fs.ts", diff --git a/src/background-plugin.ts b/src/background-plugin.ts index e641490..c20c2a4 100644 --- a/src/background-plugin.ts +++ b/src/background-plugin.ts @@ -349,7 +349,7 @@ class HttpContext { export async function createBackgroundPlugin( opts: InternalConfig, names: string[], - modules: ArrayBuffer[], + modules: WebAssembly.Module[], ): Promise { const worker = new Worker(WORKER_URL); const context = new CallContext(SharedArrayBuffer, opts.logger, opts.config); @@ -394,7 +394,7 @@ export async function createBackgroundPlugin( }); }); - worker.postMessage(message, modules); + worker.postMessage(message); await onready; return new BackgroundPlugin(worker, sharedData, opts, context); diff --git a/src/foreground-plugin.ts b/src/foreground-plugin.ts index 3536095..2a8110c 100644 --- a/src/foreground-plugin.ts +++ b/src/foreground-plugin.ts @@ -4,17 +4,15 @@ import { loadWasi } from 'js-sdk:wasi'; export const EXTISM_ENV = 'extism:host/env'; +type InstantiatedModule = { guestType: string; module: WebAssembly.Module; instance: WebAssembly.Instance }; + export class ForegroundPlugin { #context: CallContext; - #modules: { guestType: string; module: WebAssembly.WebAssemblyInstantiatedSource }[]; + #modules: InstantiatedModule[]; #names: string[]; #active: boolean = false; - constructor( - context: CallContext, - names: string[], - modules: { guestType: string; module: WebAssembly.WebAssemblyInstantiatedSource }[], - ) { + constructor(context: CallContext, names: string[], modules: InstantiatedModule[]) { this.#context = context; this.#names = names; this.#modules = modules; @@ -41,7 +39,7 @@ export class ForegroundPlugin { ? [this.lookupTarget(search[0]), search[1]] : [ this.#modules.find((guest) => { - const exports = WebAssembly.Module.exports(guest.module.module); + const exports = WebAssembly.Module.exports(guest.module); return exports.find((item) => { return item.name === search[0] && item.kind === 'function'; }); @@ -53,7 +51,7 @@ export class ForegroundPlugin { return false; } - const func = target.module.instance.exports[name] as any; + const func = target.instance.exports[name] as any; if (!func) { return false; @@ -74,7 +72,7 @@ export class ForegroundPlugin { ? [this.lookupTarget(search[0]), search[1]] : [ this.#modules.find((guest) => { - const exports = WebAssembly.Module.exports(guest.module.module); + const exports = WebAssembly.Module.exports(guest.module); return exports.find((item) => { return item.name === search[0] && item.kind === 'function'; }); @@ -85,7 +83,7 @@ export class ForegroundPlugin { if (!target) { throw Error(`Plugin error: target "${search.join('" "')}" does not exist`); } - const func = target.module.instance.exports[name] as any; + const func = target.instance.exports[name] as any; if (!func) { throw Error(`Plugin error: function "${search.join('" "')}" does not exist`); } @@ -124,7 +122,7 @@ export class ForegroundPlugin { return output; } - private lookupTarget(name: any): { guestType: string; module: WebAssembly.WebAssemblyInstantiatedSource } { + private lookupTarget(name: any): InstantiatedModule { const target = String(name ?? '0'); const idx = this.#names.findIndex((xs) => xs === target); if (idx === -1) { @@ -134,15 +132,15 @@ export class ForegroundPlugin { } async getExports(name?: string): Promise { - return WebAssembly.Module.exports(this.lookupTarget(name).module.module) || []; + return WebAssembly.Module.exports(this.lookupTarget(name).module) || []; } async getImports(name?: string): Promise { - return WebAssembly.Module.imports(this.lookupTarget(name).module.module) || []; + return WebAssembly.Module.imports(this.lookupTarget(name).module) || []; } async getInstance(name?: string): Promise { - return this.lookupTarget(name).module.instance; + return this.lookupTarget(name).instance; } async close(): Promise { @@ -153,7 +151,7 @@ export class ForegroundPlugin { export async function createForegroundPlugin( opts: InternalConfig, names: string[], - sources: ArrayBuffer[], + modules: WebAssembly.Module[], context: CallContext = new CallContext(ArrayBuffer, opts.logger, opts.config), ): Promise { const wasi = opts.wasiEnabled ? await loadWasi(opts.allowedPaths) : null; @@ -171,27 +169,27 @@ export async function createForegroundPlugin( } } - const modules = await Promise.all( - sources.map(async (source) => { - const module = await WebAssembly.instantiate(source, imports); + const instances = await Promise.all( + modules.map(async (module) => { + const instance = await WebAssembly.instantiate(module, imports); if (wasi) { - await wasi?.initialize(module.instance); + await wasi?.initialize(instance); } - const guestType = module.instance.exports.hs_init + const guestType = instance.exports.hs_init ? 'haskell' - : module.instance.exports._initialize + : instance.exports._initialize ? 'reactor' - : module.instance.exports._start + : instance.exports._start ? 'command' : 'none'; - const initRuntime: any = module.instance.exports.hs_init ? module.instance.exports.hs_init : () => {}; + const initRuntime: any = instance.exports.hs_init ? instance.exports.hs_init : () => {}; initRuntime(); - return { module, guestType }; + return { module, instance, guestType }; }), ); - return new ForegroundPlugin(context, names, modules); + return new ForegroundPlugin(context, names, instances); } diff --git a/src/interfaces.ts b/src/interfaces.ts index 4107035..5022376 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -203,7 +203,21 @@ export interface ManifestWasmPath { } /** - * The WASM to load as bytes, a path, or a url + * Represents a WASM module as a response + */ +export interface ManifestWasmResponse { + response: Response; +} + +/** + * Represents a WASM module as a response + */ +export interface ManifestWasmModule { + module: WebAssembly.Module; +} + +/** + * The WASM to load as bytes, a path, a fetch `Response`, a `WebAssembly.Module`, or a url * * @property name The name of the Wasm module. Used when disambiguating {@link Plugin#call | `Plugin#call`} targets when the * plugin embeds multiple Wasm modules. @@ -211,8 +225,17 @@ export interface ManifestWasmPath { * @property hash The expected SHA-256 hash of the associated Wasm module data. {@link createPlugin} validates incoming Wasm against * provided hashes. If running on Node v18, `node` must be invoked using the `--experimental-global-webcrypto` flag. * + * ⚠️ `module` cannot be used in conjunction with `hash`: the Web Platform does not currently provide a way to get source + * bytes from a `WebAssembly.Module` in order to hash. + * */ -export type ManifestWasm = (ManifestWasmUrl | ManifestWasmData | ManifestWasmPath) & { +export type ManifestWasm = ( + | ManifestWasmUrl + | ManifestWasmData + | ManifestWasmPath + | ManifestWasmResponse + | ManifestWasmModule +) & { name?: string | undefined; hash?: string | undefined; }; @@ -241,9 +264,9 @@ export interface Manifest { /** * Any type that can be converted into an Extism {@link Manifest}. * - `object` instances that implement {@link Manifest} are validated. - * - `ArrayBuffer` instances are converted into {@link Manifest}s with a single {@link ManifestWasmData} member. + * - `ArrayBuffer` instances are converted into {@link Manifest}s with a single {@link ManifestUint8Array} member. * - `URL` instances are fetched and their responses interpreted according to their `content-type` response header. `application/wasm` and `application/octet-stream` items - * are treated as {@link ManifestWasmData} items; `application/json` and `text/json` are treated as JSON-encoded {@link Manifest}s. + * are treated as {@link ManifestUint8Array} items; `application/json` and `text/json` are treated as JSON-encoded {@link Manifest}s. * - `string` instances that start with `http://`, `https://`, or `file://` are treated as URLs. * - `string` instances that start with `{` treated as JSON-encoded {@link Manifest}s. * - All other `string` instances are treated as {@link ManifestWasmPath}. @@ -266,7 +289,7 @@ export interface Manifest { * @throws {@link TypeError} when `URL` parameters don't resolve to a known `content-type` * @throws {@link TypeError} when the resulting {@link Manifest} does not contain a `wasm` member with valid {@link ManifestWasm} items. */ -export type ManifestLike = Manifest | ArrayBuffer | string | URL; +export type ManifestLike = Manifest | Response | WebAssembly.Module | ArrayBuffer | string | URL; export interface Capabilities { /** diff --git a/src/manifest.ts b/src/manifest.ts index dfcb51b..c46b9cc 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -1,11 +1,24 @@ -import type { Manifest, ManifestWasmUrl, ManifestWasmData, ManifestWasmPath, ManifestLike } from './interfaces.ts'; +import type { + Manifest, + ManifestWasmUrl, + ManifestWasmData, + ManifestWasmPath, + ManifestWasmResponse, + ManifestWasmModule, + ManifestLike, +} from './interfaces.ts'; import { readFile } from 'js-sdk:fs'; +import { responseToModule } from 'js-sdk:response-to-module'; async function _populateWasmField(candidate: ManifestLike, _fetch: typeof fetch): Promise { if (candidate instanceof ArrayBuffer) { return { wasm: [{ data: new Uint8Array(candidate as ArrayBuffer) }] }; } + if (candidate instanceof WebAssembly.Module) { + return { wasm: [{ module: candidate as WebAssembly.Module }] }; + } + if (typeof candidate === 'string') { if (candidate.search(/^\s*\{/g) === 0) { return JSON.parse(candidate); @@ -18,24 +31,28 @@ async function _populateWasmField(candidate: ManifestLike, _fetch: typeof fetch) candidate = new URL(candidate); } - if (candidate instanceof URL) { - const response = await _fetch(candidate, { redirect: 'follow' }); + if (candidate?.constructor?.name === 'Response') { + const response: Response = candidate as Response; const contentType = response.headers.get('content-type') || 'application/octet-stream'; switch (contentType.split(';')[0]) { case 'application/octet-stream': case 'application/wasm': - return _populateWasmField(await response.arrayBuffer(), _fetch); + return { wasm: [{ response }] }; case 'application/json': case 'text/json': return _populateWasmField(JSON.parse(await response.text()), _fetch); default: throw new TypeError( - `While processing manifest URL "${candidate}"; expected content-type of "text/json", "application/json", "application/octet-stream", or "application/wasm"; got "${contentType}" after stripping off charset.`, + `While processing manifest URL "${response.url}"; expected content-type of "text/json", "application/json", "application/octet-stream", or "application/wasm"; got "${contentType}" after stripping off charset.`, ); } } + if (candidate instanceof URL) { + return _populateWasmField(await _fetch(candidate, { redirect: 'follow' }), _fetch); + } + if (!('wasm' in candidate)) { throw new TypeError('Expected "wasm" key in manifest'); } @@ -54,42 +71,61 @@ async function _populateWasmField(candidate: ManifestLike, _fetch: typeof fetch) return { ...(candidate as Manifest) }; } -export async function intoManifest(candidate: ManifestLike, _fetch: typeof fetch = fetch): Promise { +async function intoManifest(candidate: ManifestLike, _fetch: typeof fetch = fetch): Promise { const manifest = (await _populateWasmField(candidate, _fetch)) as Manifest; manifest.config ??= {}; return manifest; } -export async function toWasmModuleData(manifest: Manifest, _fetch: typeof fetch): Promise<[string[], ArrayBuffer[]]> { +export async function toWasmModuleData( + input: ManifestLike, + _fetch: typeof fetch, +): Promise<[string[], WebAssembly.Module[]]> { const names: string[] = []; + const manifest = await intoManifest(input, _fetch); + const manifestsWasm = await Promise.all( manifest.wasm.map(async (item, idx) => { - let buffer: ArrayBuffer; + let module: WebAssembly.Module; + let buffer: ArrayBuffer | undefined; if ((item as ManifestWasmData).data) { const data = (item as ManifestWasmData).data; - - if ((data as Uint8Array).buffer) { - buffer = data.buffer; - } else { - buffer = data as ArrayBuffer; - } + buffer = data.buffer ? data.buffer : data; + module = await WebAssembly.compile(data); } else if ((item as ManifestWasmPath).path) { const path = (item as ManifestWasmPath).path; const data = await readFile(path); buffer = data.buffer as ArrayBuffer; - } else { + module = await WebAssembly.compile(data); + } else if ((item as ManifestWasmUrl).url) { const response = await _fetch((item as ManifestWasmUrl).url, { headers: { accept: 'application/wasm;q=0.9,application/octet-stream;q=0.8', 'user-agent': 'extism', }, }); - - buffer = await response.arrayBuffer(); + const result = await responseToModule(response, Boolean(item.hash)); + buffer = result.data; + module = result.module; + } else if ((item as ManifestWasmResponse).response) { + const result = await responseToModule((item as ManifestWasmResponse).response, Boolean(item.hash)); + buffer = result.data; + module = result.module; + } else if ((item as ManifestWasmModule).module) { + (names[idx]) = item.name ?? String(idx); + return (item as ManifestWasmModule).module; + } else { + throw new Error( + `Unrecognized wasm item at index ${idx}. Keys include: "${Object.keys(item).sort().join(',')}"`, + ); } if (item.hash) { + if (!buffer) { + throw new Error('Item specified a hash but WebAssembly.Module source data is unavailable for hashing'); + } + const hashBuffer = new Uint8Array(await crypto.subtle.digest('SHA-256', buffer)); const checkBuffer = new Uint8Array(32); let eq = true; @@ -108,7 +144,7 @@ export async function toWasmModuleData(manifest: Manifest, _fetch: typeof fetch) } (names[idx]) = item.name ?? String(idx); - return buffer; + return module; }), ); diff --git a/src/mod.test.ts b/src/mod.test.ts index 17d014d..ce22bde 100644 --- a/src/mod.test.ts +++ b/src/mod.test.ts @@ -25,6 +25,36 @@ if (typeof WebAssembly === 'undefined') { } }); + test('createPlugin loads a WebAssembly.Module', async () => { + const response = await fetch('http://localhost:8124/wasm/code.wasm'); + const arrayBuffer = await response.arrayBuffer(); + const module = await WebAssembly.compile(arrayBuffer); + + const plugin = await createPlugin(module, { useWasi: true }); + + try { + assert(await plugin.functionExists('count_vowels'), 'count_vowels should exist'); + assert(await plugin.functionExists(['0', 'count_vowels']), '0:count_vowels should exist'); + assert(!(await plugin.functionExists(['dne', 'count_vowels'])), 'dne:count_vowels should not exist'); + assert(!(await plugin.functionExists('count_sheep')), 'count_sheep should not exist'); + } finally { + await plugin.close(); + } + }); + + test('createPlugin loads a fetch Response', async () => { + const plugin = await createPlugin(fetch('http://localhost:8124/wasm/code.wasm'), { useWasi: true }); + + try { + assert(await plugin.functionExists('count_vowels'), 'count_vowels should exist'); + assert(await plugin.functionExists(['0', 'count_vowels']), '0:count_vowels should exist'); + assert(!(await plugin.functionExists(['dne', 'count_vowels'])), 'dne:count_vowels should not exist'); + assert(!(await plugin.functionExists('count_sheep')), 'count_sheep should not exist'); + } finally { + await plugin.close(); + } + }); + if (!CAPABILITIES.crossOriginChecksEnforced) { test('can create plugin from url with hash check', async () => { const plugin = await createPlugin({ diff --git a/src/mod.ts b/src/mod.ts index 3f3dc79..3d3a105 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -2,7 +2,7 @@ import { CAPABILITIES } from 'js-sdk:capabilities'; import type { ManifestLike, InternalConfig, ExtismPluginOptions, Plugin } from './interfaces.ts'; -import { intoManifest as _intoManifest, toWasmModuleData as _toWasmModuleData } from './manifest.ts'; +import { toWasmModuleData as _toWasmModuleData } from './manifest.ts'; import { createForegroundPlugin as _createForegroundPlugin } from './foreground-plugin.ts'; import { createBackgroundPlugin as _createBackgroundPlugin } from './background-plugin.ts'; @@ -13,6 +13,8 @@ export type { Capabilities, ExtismPluginOptions, ManifestLike, + ManifestWasmResponse, + ManifestWasmModule, ManifestWasmData, ManifestWasmUrl, ManifestWasmPath, @@ -64,7 +66,6 @@ export async function createPlugin( manifest: ManifestLike | PromiseLike, opts: ExtismPluginOptions = {}, ): Promise { - manifest = await _intoManifest(await Promise.resolve(manifest)); opts = { ...opts }; opts.useWasi ??= false; opts.functions = opts.functions || {}; @@ -72,6 +73,7 @@ export async function createPlugin( opts.allowedHosts ??= [].concat(opts.allowedHosts || []); opts.logger ??= console; opts.config ??= {}; + opts.fetch ??= fetch; opts.runInWorker ??= CAPABILITIES.hasWorkerCapability; if (opts.runInWorker && !CAPABILITIES.hasWorkerCapability) { @@ -80,7 +82,7 @@ export async function createPlugin( ); } - const [names, moduleData] = await _toWasmModuleData(manifest, opts.fetch ?? fetch); + const [names, moduleData] = await _toWasmModuleData(await Promise.resolve(manifest), opts.fetch ?? fetch); const ic: InternalConfig = { allowedHosts: opts.allowedHosts as [], diff --git a/src/polyfills/bun-response-to-module.ts b/src/polyfills/bun-response-to-module.ts new file mode 100644 index 0000000..07953c4 --- /dev/null +++ b/src/polyfills/bun-response-to-module.ts @@ -0,0 +1,23 @@ +// XXX(chrisdickinson): BUN NOTE: bun doesn't support `WebAssembly.compileStreaming` at the time of writing, nor +// does cloning a response work [1]. +// +// [1]: https://github.com/oven-sh/bun/issues/6348 +export async function responseToModule( + response: Response, + _hasHash?: boolean, +): Promise<{ module: WebAssembly.Module; data?: ArrayBuffer }> { + if (String(response.headers.get('Content-Type')).split(';')[0] === 'application/octet-stream') { + const headers = new Headers(response.headers); + headers.set('Content-Type', 'application/wasm'); + + response = new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: headers, + }); + } + const data = await response.arrayBuffer(); + const module = await WebAssembly.compile(data); + + return { module, data }; +} diff --git a/src/polyfills/response-to-module.ts b/src/polyfills/response-to-module.ts new file mode 100644 index 0000000..b157a63 --- /dev/null +++ b/src/polyfills/response-to-module.ts @@ -0,0 +1,26 @@ +export async function responseToModule( + response: Response, + hasHash?: boolean, +): Promise<{ module: WebAssembly.Module; data?: ArrayBuffer }> { + if (String(response.headers.get('Content-Type')).split(';')[0] === 'application/octet-stream') { + const headers = new Headers(response.headers); + headers.set('Content-Type', 'application/wasm'); + + response = new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: headers, + }); + } + + // XXX(chrisdickinson): Note that we want to pass a `Response` to WebAssembly.compileStreaming if we + // can to play nicely with V8's code caching [1]. At the same time, we need the original ArrayBuffer data + // to verify any hashes. There's no way back to bytes from `WebAssembly.Module`, so we have to `.clone()` + // the response to get the `ArrayBuffer` data if we need to check a hash. + // + // [1]: https://v8.dev/blog/wasm-code-caching#algorithm + const data = hasHash ? await response.clone().arrayBuffer() : undefined; + const module = await WebAssembly.compileStreaming(response); + + return { module, data }; +} diff --git a/src/worker.ts b/src/worker.ts index 930167b..d4e28eb 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -122,7 +122,7 @@ class Reactor { ev: InternalConfig & { type: string; names: string[]; - modules: ArrayBuffer[]; + modules: WebAssembly.Module[]; sharedData: SharedArrayBuffer; functions: { [name: string]: string[] }; }, diff --git a/types/js-sdk/index.d.ts b/types/js-sdk/index.d.ts index 8f13e5c..e42a0e8 100644 --- a/types/js-sdk/index.d.ts +++ b/types/js-sdk/index.d.ts @@ -1,3 +1,7 @@ +declare module 'js-sdk:response-to-module' { + export function responseToModule(response: Response, hasHash?: boolean): Promise<{module: WebAssembly.Module, data?: ArrayBuffer}>; +} + declare module 'js-sdk:capabilities' { import type { Capabilities } from '../../src/interfaces';