Skip to content

Commit 0bc6ef4

Browse files
feat(jspi): enable http_request via JSPI
1 parent dc7edae commit 0bc6ef4

File tree

6 files changed

+193
-138
lines changed

6 files changed

+193
-138
lines changed

justfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ build_worker_node out='worker/node' args='[]':
152152
"platform": "node",
153153
"polyfills": {
154154
"./src/polyfills/deno-capabilities.ts": "./src/polyfills/node-capabilities.ts",
155+
"./src/polyfills/deno-minimatch.ts": "./src/polyfills/node-minimatch.ts",
155156
"./src/polyfills/node-fs.ts": "node:fs/promises",
156157
"./src/polyfills/deno-wasi.ts": "./src/polyfills/node-wasi.ts",
157158
}
@@ -170,6 +171,7 @@ build_worker_browser out='worker/browser' args='[]':
170171
},
171172
"polyfills": {
172173
"./src/polyfills/deno-capabilities.ts": "./src/polyfills/browser-capabilities.ts",
174+
"./src/polyfills/deno-minimatch.ts": "./src/polyfills/node-minimatch.ts",
173175
"./src/polyfills/node-fs.ts": "./src/polyfills/browser-fs.ts",
174176
"./src/polyfills/deno-wasi.ts": "./src/polyfills/browser-wasi.ts",
175177
}

src/background-plugin.ts

Lines changed: 1 addition & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,15 @@ import {
1111
} from './call-context.ts';
1212
import {
1313
type InternalConfig,
14-
MemoryOptions,
1514
PluginOutput,
1615
SAB_BASE_OFFSET,
1716
SharedArrayBufferSection,
1817
} from './interfaces.ts';
19-
import { readBodyUpTo } from './utils.ts';
2018
import { WORKER_URL } from './worker-url.ts';
2119
import { Worker } from 'node:worker_threads';
2220
import { CAPABILITIES } from './polyfills/deno-capabilities.ts';
2321
import { EXTISM_ENV } from './foreground-plugin.ts';
24-
import { matches } from './polyfills/deno-minimatch.ts';
22+
import { HttpContext } from './http-context.ts'
2523

2624
// Firefox has not yet implemented Atomics.waitAsync, but we can polyfill
2725
// it using a worker as a one-off.
@@ -496,95 +494,6 @@ class RingBufferWriter {
496494
}
497495
}
498496

499-
class HttpContext {
500-
fetch: typeof fetch;
501-
lastStatusCode: number;
502-
lastHeaders: Record<string, string> | null;
503-
allowedHosts: string[];
504-
memoryOptions: MemoryOptions;
505-
506-
constructor(
507-
_fetch: typeof fetch,
508-
allowedHosts: string[],
509-
memoryOptions: MemoryOptions,
510-
allowResponseHeaders: boolean,
511-
) {
512-
this.fetch = _fetch;
513-
this.allowedHosts = allowedHosts;
514-
this.lastStatusCode = 0;
515-
this.memoryOptions = memoryOptions;
516-
this.lastHeaders = allowResponseHeaders ? {} : null;
517-
}
518-
519-
contribute(functions: Record<string, Record<string, any>>) {
520-
functions[EXTISM_ENV] ??= {};
521-
functions[EXTISM_ENV].http_request = (callContext: CallContext, reqaddr: bigint, bodyaddr: bigint) =>
522-
this.makeRequest(callContext, reqaddr, bodyaddr);
523-
functions[EXTISM_ENV].http_status_code = () => this.lastStatusCode;
524-
functions[EXTISM_ENV].http_headers = (callContext: CallContext) => {
525-
if (this.lastHeaders === null) {
526-
return 0n;
527-
}
528-
return callContext.store(JSON.stringify(this.lastHeaders));
529-
};
530-
}
531-
532-
async makeRequest(callContext: CallContext, reqaddr: bigint, bodyaddr: bigint) {
533-
if (this.lastHeaders !== null) {
534-
this.lastHeaders = {};
535-
}
536-
this.lastStatusCode = 0;
537-
538-
const req = callContext.read(reqaddr);
539-
if (req === null) {
540-
return 0n;
541-
}
542-
543-
const { headers, header, url: rawUrl, method: m } = req.json();
544-
const method = m ?? 'GET';
545-
const url = new URL(rawUrl);
546-
547-
const isAllowed = this.allowedHosts.some((allowedHost) => {
548-
return allowedHost === url.hostname || matches(url.hostname, allowedHost);
549-
});
550-
551-
if (!isAllowed) {
552-
throw new Error(`Call error: HTTP request to "${url}" is not allowed (no allowedHosts match "${url.hostname}")`);
553-
}
554-
555-
const body = bodyaddr === 0n || method === 'GET' || method === 'HEAD' ? null : callContext.read(bodyaddr)?.bytes();
556-
const fetch = this.fetch;
557-
const response = await fetch(rawUrl, {
558-
headers: headers || header,
559-
method,
560-
...(body ? { body: body.slice() } : {}),
561-
});
562-
563-
this.lastStatusCode = response.status;
564-
565-
if (this.lastHeaders !== null) {
566-
this.lastHeaders = Object.fromEntries(response.headers);
567-
}
568-
569-
try {
570-
const bytes = this.memoryOptions.maxHttpResponseBytes
571-
? await readBodyUpTo(response, this.memoryOptions.maxHttpResponseBytes)
572-
: new Uint8Array(await response.arrayBuffer());
573-
574-
const result = callContext.store(bytes);
575-
576-
return result;
577-
} catch (err) {
578-
if (err instanceof Error) {
579-
const ptr = callContext.store(new TextEncoder().encode(err.message));
580-
callContext[ENV].log_error(ptr);
581-
return 0n;
582-
}
583-
return 0n;
584-
}
585-
}
586-
}
587-
588497
export async function createBackgroundPlugin(
589498
opts: InternalConfig,
590499
names: string[],

src/foreground-plugin.ts

Lines changed: 56 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,17 @@ import { BEGIN, CallContext, END, ENV, GET_BLOCK, RESET, SET_HOST_CONTEXT, STORE
22
import { type InternalConfig, InternalWasi, PluginOutput } from './interfaces.ts';
33
import { CAPABILITIES } from './polyfills/deno-capabilities.ts';
44
import { loadWasi } from './polyfills/deno-wasi.ts';
5+
import { HttpContext } from './http-context.ts';
56

67
export const EXTISM_ENV = 'extism:host/env';
78

89
type InstantiatedModule = [WebAssembly.Module, WebAssembly.Instance];
910

1011
interface SuspendingCtor {
11-
new (fn: CallableFunction): any;
12+
new(fn: CallableFunction): any;
1213
}
1314

14-
const AsyncFunction = (async () => {}).constructor;
15+
const AsyncFunction = (async () => { }).constructor;
1516
const Suspending: SuspendingCtor | undefined = (WebAssembly as any).Suspending;
1617
const promising: CallableFunction | undefined = (WebAssembly as any).promising;
1718

@@ -140,7 +141,7 @@ export async function createForegroundPlugin(
140141
const isAsync = func.constructor === AsyncFunction;
141142
suspendsOnInvoke ||= isAsync;
142143
const wrapped = func.bind(null, context);
143-
imports[namespace][name] = isAsync ? new (WebAssembly as any).Suspending(wrapped) : wrapped;
144+
imports[namespace][name] = isAsync ? new Suspending!(wrapped) : wrapped;
144145
}
145146
}
146147

@@ -158,12 +159,14 @@ export async function createForegroundPlugin(
158159
const seen: Map<WebAssembly.Module, WebAssembly.Instance> = new Map();
159160
const wasiList: InternalWasi[] = [];
160161

161-
const instance = await instantiateModule(['main'], modules[mainIndex], imports, opts, wasiList, names, modules, seen);
162+
const mutableFlags = { suspendsOnInvoke }
163+
const instance = await instantiateModule(context, ['main'], modules[mainIndex], imports, opts, wasiList, names, modules, seen, mutableFlags);
162164

163-
return new ForegroundPlugin(opts, context, [modules[mainIndex], instance], wasiList, suspendsOnInvoke);
165+
return new ForegroundPlugin(opts, context, [modules[mainIndex], instance], wasiList, mutableFlags.suspendsOnInvoke);
164166
}
165167

166168
async function instantiateModule(
169+
context: CallContext,
167170
current: string[],
168171
module: WebAssembly.Module,
169172
imports: Record<string, Record<string, any>>,
@@ -172,6 +175,7 @@ async function instantiateModule(
172175
names: string[],
173176
modules: WebAssembly.Module[],
174177
linked: Map<WebAssembly.Module, WebAssembly.Instance | null>,
178+
mutableFlags: { suspendsOnInvoke: boolean }
175179
) {
176180
linked.set(module, null);
177181

@@ -216,6 +220,45 @@ async function instantiateModule(
216220
);
217221
}
218222

223+
// XXX(chrisdickinson): This is a bit of a hack, admittedly. So what's going on here?
224+
//
225+
// JSPI is going on here. Let me explain: at the time of writing, the js-sdk supports
226+
// JSPI by detecting AsyncFunction use in the `functions` parameter. When we detect an
227+
// async function in imports we _must_ mark all exported Wasm functions as "promising" --
228+
// that is, they might call a host function that suspends the stack.
229+
//
230+
// If we were to mark extism's http_request as async, we would _always_ set exports as
231+
// "promising". This adds unnecessary overhead for folks who aren't using `http_request`.
232+
// Instead, we detect if any of the manifest items *import* `http_request`. If they
233+
// haven't overridden the default CallContext implementation, we provide an HttpContext
234+
// on-demand.
235+
//
236+
// Unfortuantely this duplicates a little bit of logic-- in particular, we have to bind
237+
// CallContext to each of the HttpContext contributions (See "REBIND" below.)
238+
if (
239+
module === EXTISM_ENV &&
240+
name === 'http_request' &&
241+
promising &&
242+
imports[module][name] === context[ENV].http_request
243+
) {
244+
const httpContext = new HttpContext(
245+
opts.fetch,
246+
opts.allowedHosts,
247+
opts.memory,
248+
opts.allowHttpResponseHeaders
249+
);
250+
251+
mutableFlags.suspendsOnInvoke = true
252+
253+
const contributions = {} as any
254+
httpContext.contribute(contributions)
255+
for (const [key, entry] of Object.entries(contributions[EXTISM_ENV] as { [k: string]: CallableFunction })) {
256+
// REBIND:
257+
imports[module][key] = (entry as any).bind(null, context)
258+
}
259+
imports[module][name] = new Suspending!(imports[module][name])
260+
}
261+
219262
switch (kind) {
220263
case `function`: {
221264
instantiationImports[module] ??= {};
@@ -246,11 +289,11 @@ async function instantiateModule(
246289

247290
// If the dependency provides "_start", treat it as a WASI Command module; instantiate it (and its subtree) directly.
248291
const instance = providerExports.find((xs) => xs.name === '_start')
249-
? await instantiateModule([...current, module], provider, imports, opts, wasiList, names, modules, new Map())
292+
? await instantiateModule(context, [...current, module], provider, imports, opts, wasiList, names, modules, new Map(), mutableFlags)
250293
: !linked.has(provider)
251-
? (await instantiateModule([...current, module], provider, imports, opts, wasiList, names, modules, linked),
252-
linked.get(provider))
253-
: linked.get(provider);
294+
? (await instantiateModule(context, [...current, module], provider, imports, opts, wasiList, names, modules, linked, mutableFlags),
295+
linked.get(provider))
296+
: linked.get(provider);
254297

255298
if (!instance) {
256299
// circular import, either make a trampoline or bail
@@ -291,10 +334,10 @@ async function instantiateModule(
291334
const guestType = instance.exports.hs_init
292335
? 'haskell'
293336
: instance.exports._initialize
294-
? 'reactor'
295-
: instance.exports._start
296-
? 'command'
297-
: 'none';
337+
? 'reactor'
338+
: instance.exports._start
339+
? 'command'
340+
: 'none';
298341

299342
if (wasi) {
300343
await wasi?.initialize(instance);

src/http-context.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import {
2+
CallContext,
3+
ENV,
4+
} from './call-context.ts';
5+
import {
6+
MemoryOptions,
7+
} from './interfaces.ts';
8+
import { EXTISM_ENV } from './foreground-plugin.ts';
9+
import { matches } from './polyfills/deno-minimatch.ts';
10+
11+
export class HttpContext {
12+
fetch: typeof fetch;
13+
lastStatusCode: number;
14+
lastHeaders: Record<string, string> | null;
15+
allowedHosts: string[];
16+
memoryOptions: MemoryOptions;
17+
18+
constructor(
19+
_fetch: typeof fetch,
20+
allowedHosts: string[],
21+
memoryOptions: MemoryOptions,
22+
allowResponseHeaders: boolean,
23+
) {
24+
this.fetch = _fetch;
25+
this.allowedHosts = allowedHosts;
26+
this.lastStatusCode = 0;
27+
this.memoryOptions = memoryOptions;
28+
this.lastHeaders = allowResponseHeaders ? {} : null;
29+
}
30+
31+
contribute(functions: Record<string, Record<string, any>>) {
32+
functions[EXTISM_ENV] ??= {};
33+
functions[EXTISM_ENV].http_request = (callContext: CallContext, reqaddr: bigint, bodyaddr: bigint) =>
34+
this.makeRequest(callContext, reqaddr, bodyaddr);
35+
functions[EXTISM_ENV].http_status_code = () => this.lastStatusCode;
36+
functions[EXTISM_ENV].http_headers = (callContext: CallContext) => {
37+
if (this.lastHeaders === null) {
38+
return 0n;
39+
}
40+
return callContext.store(JSON.stringify(this.lastHeaders));
41+
};
42+
}
43+
44+
async makeRequest(callContext: CallContext, reqaddr: bigint, bodyaddr: bigint) {
45+
if (this.lastHeaders !== null) {
46+
this.lastHeaders = {};
47+
}
48+
this.lastStatusCode = 0;
49+
50+
const req = callContext.read(reqaddr);
51+
if (req === null) {
52+
return 0n;
53+
}
54+
55+
const { headers, header, url: rawUrl, method: m } = req.json();
56+
const method = m ?? 'GET';
57+
const url = new URL(rawUrl);
58+
59+
const isAllowed = this.allowedHosts.some((allowedHost) => {
60+
return allowedHost === url.hostname || matches(url.hostname, allowedHost);
61+
});
62+
63+
if (!isAllowed) {
64+
throw new Error(`Call error: HTTP request to "${url}" is not allowed (no allowedHosts match "${url.hostname}")`);
65+
}
66+
67+
const body = bodyaddr === 0n || method === 'GET' || method === 'HEAD' ? null : callContext.read(bodyaddr)?.bytes();
68+
const fetch = this.fetch;
69+
const response = await fetch(rawUrl, {
70+
headers: headers || header,
71+
method,
72+
...(body ? { body: body.slice() } : {}),
73+
});
74+
75+
this.lastStatusCode = response.status;
76+
77+
if (this.lastHeaders !== null) {
78+
this.lastHeaders = Object.fromEntries(response.headers);
79+
}
80+
81+
try {
82+
const bytes = this.memoryOptions.maxHttpResponseBytes
83+
? await readBodyUpTo(response, this.memoryOptions.maxHttpResponseBytes)
84+
: new Uint8Array(await response.arrayBuffer());
85+
86+
const result = callContext.store(bytes);
87+
88+
return result;
89+
} catch (err) {
90+
if (err instanceof Error) {
91+
const ptr = callContext.store(new TextEncoder().encode(err.message));
92+
callContext[ENV].log_error(ptr);
93+
return 0n;
94+
}
95+
return 0n;
96+
}
97+
}
98+
}
99+
100+
async function readBodyUpTo(response: Response, maxBytes: number): Promise<Uint8Array> {
101+
const reader = response.body?.getReader();
102+
if (!reader) {
103+
return new Uint8Array(0);
104+
}
105+
106+
let receivedLength = 0;
107+
const chunks = [];
108+
109+
while (receivedLength < maxBytes) {
110+
const { done, value } = await reader.read();
111+
if (done) {
112+
break;
113+
}
114+
chunks.push(value);
115+
receivedLength += value.length;
116+
if (receivedLength >= maxBytes) {
117+
throw new Error(`Response body exceeded ${maxBytes} bytes`);
118+
}
119+
}
120+
121+
const limitedResponseBody = new Uint8Array(receivedLength);
122+
let position = 0;
123+
for (const chunk of chunks) {
124+
limitedResponseBody.set(chunk, position);
125+
position += chunk.length;
126+
}
127+
128+
return limitedResponseBody;
129+
}

0 commit comments

Comments
 (0)