@aresdefencelabs/wasm-http-runtime 0.1.4 → 0.1.7

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,578 @@
1
+ export function createWasmHttpRuntime(config) {
2
+ const {
3
+ wasmModule,
4
+ createModule,
5
+ debug = false,
6
+ responseTtlMs = 60_000,
7
+ maxHttpResponseHandles = 128,
8
+ maxTotalBridgeBytes = 8 * 1024 * 1024,
9
+ maxResponseBodyBytes = 2 * 1024 * 1024,
10
+ onLog = null,
11
+ onError = null,
12
+ beforeOutboundFetch = null,
13
+ afterOutboundFetch = null,
14
+ } = config ?? {};
15
+
16
+ if (!wasmModule) {
17
+ throw new Error("createWasmHttpRuntime: wasmModule is required");
18
+ }
19
+
20
+ if (typeof createModule !== "function") {
21
+ throw new Error("createWasmHttpRuntime: createModule is required");
22
+ }
23
+
24
+ const encoder = new TextEncoder();
25
+ const decoder = new TextDecoder();
26
+
27
+ const state = {
28
+ instance: null,
29
+ requestContext: null,
30
+ nextHttpResponseId: 1,
31
+ httpResponses: new Map(),
32
+ options: {
33
+ debug,
34
+ responseTtlMs,
35
+ maxHttpResponseHandles,
36
+ maxTotalBridgeBytes,
37
+ maxResponseBodyBytes,
38
+ onLog,
39
+ onError,
40
+ beforeOutboundFetch,
41
+ afterOutboundFetch,
42
+ },
43
+ };
44
+
45
+ let instancePromise = null;
46
+ let currentInstance = null;
47
+
48
+ function log(...args) {
49
+ if (state.options.debug) {
50
+ console.log(...args);
51
+ }
52
+ }
53
+
54
+ function pickExport(mod, names) {
55
+ for (const name of names) {
56
+ const fn = mod?.[name];
57
+ if (typeof fn === "function") {
58
+ return fn;
59
+ }
60
+ }
61
+ return null;
62
+ }
63
+
64
+ function getModule(instance) {
65
+ if (!instance) {
66
+ throw new Error("WASM module is not available");
67
+ }
68
+ return instance;
69
+ }
70
+
71
+ function getHeapU8(mod) {
72
+ if (mod?.HEAPU8 instanceof Uint8Array) {
73
+ return mod.HEAPU8;
74
+ }
75
+ throw new Error("HEAPU8 is not exported on the Emscripten module");
76
+ }
77
+
78
+ function getBuffer(mod) {
79
+ return getHeapU8(mod).buffer;
80
+ }
81
+
82
+ function getCurrentBuffer() {
83
+ if (!currentInstance) {
84
+ throw new Error("WASM instance not ready");
85
+ }
86
+ return getBuffer(currentInstance);
87
+ }
88
+
89
+ function safeSetU32(ptr, value) {
90
+ if (!ptr) return;
91
+ const buffer = getCurrentBuffer();
92
+ const start = ptr >>> 0;
93
+ if (start + 4 > buffer.byteLength) return;
94
+ new DataView(buffer).setUint32(start, value >>> 0, true);
95
+ }
96
+
97
+ function safeSetU64Zero(ptr) {
98
+ if (!ptr) return;
99
+ const buffer = getCurrentBuffer();
100
+ const start = ptr >>> 0;
101
+ if (start + 8 > buffer.byteLength) return;
102
+ const view = new DataView(buffer);
103
+ view.setUint32(start, 0, true);
104
+ view.setUint32(start + 4, 0, true);
105
+ }
106
+
107
+ function headersToObject(headers) {
108
+ return Object.fromEntries(headers.entries());
109
+ }
110
+
111
+ function utf8ByteLength(value) {
112
+ return encoder.encode(value).length;
113
+ }
114
+
115
+ function normalizeHeaders(headers) {
116
+ const out = {};
117
+ for (const [key, value] of headers.entries()) {
118
+ out[key.toLowerCase()] = value;
119
+ }
120
+ return out;
121
+ }
122
+
123
+ function estimateResponseBytes(resp) {
124
+ let total = utf8ByteLength(resp.bodyText);
125
+ for (const [k, v] of Object.entries(resp.headers)) {
126
+ total += utf8ByteLength(k);
127
+ total += utf8ByteLength(v);
128
+ }
129
+ return total >>> 0;
130
+ }
131
+
132
+ function sweepExpiredResponses() {
133
+ const now = Date.now();
134
+ const ttl = state.options.responseTtlMs;
135
+
136
+ for (const [id, entry] of state.httpResponses.entries()) {
137
+ if (now - entry.createdAtMs > ttl) {
138
+ state.httpResponses.delete(id);
139
+ }
140
+ }
141
+ }
142
+
143
+ function getCurrentBridgeBytes() {
144
+ let total = 0;
145
+ for (const entry of state.httpResponses.values()) {
146
+ total += entry.estimatedBytes;
147
+ }
148
+ return total >>> 0;
149
+ }
150
+
151
+ function assertBridgeCapacity(nextEntryBytes) {
152
+ sweepExpiredResponses();
153
+
154
+ if (state.httpResponses.size >= state.options.maxHttpResponseHandles) {
155
+ throw new Error("Too many outstanding HTTP response handles");
156
+ }
157
+
158
+ const totalAfterInsert = getCurrentBridgeBytes() + nextEntryBytes;
159
+ if (totalAfterInsert > state.options.maxTotalBridgeBytes) {
160
+ throw new Error("HTTP response bridge memory budget exceeded");
161
+ }
162
+ }
163
+
164
+ function getResponseByIdOrEmpty(responseId) {
165
+ sweepExpiredResponses();
166
+
167
+ return (
168
+ state.httpResponses.get(responseId) ?? {
169
+ status: 0,
170
+ bodyText: "",
171
+ headers: {},
172
+ createdAtMs: 0,
173
+ estimatedBytes: 0,
174
+ }
175
+ );
176
+ }
177
+
178
+ function getAlloc(mod) {
179
+ const alloc = pickExport(mod, ["_alloc", "alloc", "_malloc", "malloc"]);
180
+ if (!alloc) {
181
+ throw new Error("No alloc/malloc export found");
182
+ }
183
+ return alloc;
184
+ }
185
+
186
+ function getFree(mod) {
187
+ const freeMem = pickExport(mod, ["_free_mem", "free_mem"]);
188
+ if (freeMem) {
189
+ return { fn: freeMem, takesSize: true };
190
+ }
191
+
192
+ const free = pickExport(mod, ["_free", "free"]);
193
+ if (free) {
194
+ return { fn: free, takesSize: false };
195
+ }
196
+
197
+ throw new Error("No free export found");
198
+ }
199
+
200
+ function writeCString(mod, value) {
201
+ const encoded = encoder.encode(value);
202
+ const alloc = getAlloc(mod);
203
+ const ptr = alloc(encoded.length + 1) >>> 0;
204
+
205
+ if (!ptr) {
206
+ throw new Error(`alloc failed for ${encoded.length + 1} bytes`);
207
+ }
208
+
209
+ const heap = getHeapU8(mod);
210
+
211
+ if (ptr + encoded.length + 1 > heap.length) {
212
+ throw new Error("Allocated CString buffer exceeds wasm memory bounds");
213
+ }
214
+
215
+ heap.set(encoded, ptr);
216
+ heap[ptr + encoded.length] = 0;
217
+
218
+ return {
219
+ ptr,
220
+ size: encoded.length + 1,
221
+ };
222
+ }
223
+
224
+ function freePtr(mod, ptr, size = 0) {
225
+ if (!ptr) return;
226
+
227
+ const free = getFree(mod);
228
+ if (free.takesSize) {
229
+ free.fn(ptr >>> 0, size >>> 0);
230
+ } else {
231
+ free.fn(ptr >>> 0);
232
+ }
233
+ }
234
+
235
+ function readCStringFromModule(mod, ptr, maxLen = 10_000_000) {
236
+ if (!ptr) return "";
237
+
238
+ if (typeof mod.UTF8ToString === "function") {
239
+ return mod.UTF8ToString(ptr);
240
+ }
241
+
242
+ const bytes = getHeapU8(mod);
243
+ const start = ptr >>> 0;
244
+
245
+ if (start >= bytes.length) {
246
+ throw new Error(`CString pointer out of bounds: ${ptr}`);
247
+ }
248
+
249
+ let end = start;
250
+ const limit = Math.min(bytes.length, start + maxLen);
251
+
252
+ while (end < limit && bytes[end] !== 0) {
253
+ end++;
254
+ }
255
+
256
+ if (end >= limit) {
257
+ throw new Error("CString terminator not found within safe bounds");
258
+ }
259
+
260
+ return decoder.decode(bytes.subarray(start, end));
261
+ }
262
+
263
+ function copyUtf8ToHeap(mod, value, outPtr, maxLen) {
264
+ const bytes = encoder.encode(value);
265
+ const count = Math.min(bytes.length, maxLen >>> 0);
266
+ getHeapU8(mod).set(bytes.subarray(0, count), outPtr >>> 0);
267
+ return count >>> 0;
268
+ }
269
+
270
+ function jsonResponse(status, body) {
271
+ return new Response(JSON.stringify(body), {
272
+ status,
273
+ headers: { "content-type": "application/json; charset=utf-8" },
274
+ });
275
+ }
276
+
277
+ function makeBaseImports() {
278
+ return {
279
+ env: {
280
+ emscripten_notify_memory_growth: () => {},
281
+ emscripten_date_now: () => Date.now(),
282
+ emscripten_get_now: () => performance.now(),
283
+
284
+ abort: (msg, file, line, col) => {
285
+ throw new Error(
286
+ `abort: ${String(msg)} @ ${String(file)}:${String(line)}:${String(col)}`
287
+ );
288
+ },
289
+ },
290
+
291
+ wasi_snapshot_preview1: {
292
+ fd_write(_fd, _iovs, _iovsLen, nwritten) {
293
+ safeSetU32(nwritten, 0);
294
+ return 0;
295
+ },
296
+
297
+ fd_read(_fd, _iovs, _iovsLen, nread) {
298
+ safeSetU32(nread, 0);
299
+ return 0;
300
+ },
301
+
302
+ fd_seek(_fd, _offsetLow, _offsetHigh, _whence, newOffset) {
303
+ safeSetU64Zero(newOffset);
304
+ return 0;
305
+ },
306
+
307
+ fd_close(_fd) {
308
+ return 0;
309
+ },
310
+ },
311
+ };
312
+ }
313
+
314
+ async function bootWasm() {
315
+ if (instancePromise) {
316
+ return instancePromise;
317
+ }
318
+
319
+ instancePromise = (async () => {
320
+ const baseImports = makeBaseImports();
321
+
322
+ let mod = null;
323
+
324
+ mod = await createModule({
325
+ ...baseImports,
326
+
327
+ instantiateWasm(wasmImports, successCallback) {
328
+ const finalImports = {
329
+ ...wasmImports,
330
+ env: {
331
+ ...(wasmImports.env || {}),
332
+ ...(baseImports.env || {}),
333
+ },
334
+ wasi_snapshot_preview1: {
335
+ ...(wasmImports.wasi_snapshot_preview1 || {}),
336
+ ...(baseImports.wasi_snapshot_preview1 || {}),
337
+ },
338
+ };
339
+
340
+ const instance = new WebAssembly.Instance(wasmModule, finalImports);
341
+ successCallback(instance);
342
+ return instance.exports;
343
+ },
344
+
345
+ __aresAbiLog(messagePtr) {
346
+ const message = readCStringFromModule(mod, messagePtr);
347
+
348
+ if (typeof state.options.onLog === "function") {
349
+ state.options.onLog(message);
350
+ } else if (state.options.debug) {
351
+ console.log("[AresWasm]", message);
352
+ }
353
+ },
354
+
355
+ __aresAbiHttpGetUserAgentName() {
356
+ const ctx = state.requestContext;
357
+ const userAgent =
358
+ ctx?.request?.headers.get("user-agent") ?? "Cloudflare-Worker";
359
+
360
+ const written = writeCString(mod, userAgent);
361
+ return written.ptr >>> 0;
362
+ },
363
+
364
+ __aresAbiHttpFetchBlockingAsync: async (requestJsonCstrPtr) => {
365
+ log("[AresHost] __aresAbiHttpFetchBlockingAsync entered");
366
+
367
+ try {
368
+ const requestJson = readCStringFromModule(mod, requestJsonCstrPtr);
369
+ log("[AresHost] requestJson =", requestJson);
370
+
371
+ const outbound = JSON.parse(requestJson);
372
+ log("[AresHost] parsed outbound =", outbound);
373
+
374
+ if (typeof state.options.beforeOutboundFetch === "function") {
375
+ state.options.beforeOutboundFetch(outbound);
376
+ }
377
+
378
+ log("[AresHost] before fetch:", outbound.url);
379
+
380
+ const response = await fetch(outbound.url, {
381
+ method: outbound.method ?? "GET",
382
+ headers: outbound.headers,
383
+ body: outbound.body ?? undefined,
384
+ });
385
+
386
+ log("[AresHost] fetch returned, status =", response.status);
387
+
388
+ const bodyText = await response.text();
389
+ log("[AresHost] response.text resolved, length =", bodyText.length);
390
+
391
+ if (typeof state.options.afterOutboundFetch === "function") {
392
+ state.options.afterOutboundFetch(response, bodyText, outbound);
393
+ }
394
+
395
+ const bodyBytes = utf8ByteLength(bodyText);
396
+
397
+ if (bodyBytes > state.options.maxResponseBodyBytes) {
398
+ throw new Error(
399
+ `HTTP response body too large: ${bodyBytes} bytes exceeds maxResponseBodyBytes=${state.options.maxResponseBodyBytes}`
400
+ );
401
+ }
402
+
403
+ const headers = normalizeHeaders(response.headers);
404
+ const estimatedBytes = estimateResponseBytes({
405
+ bodyText,
406
+ headers,
407
+ });
408
+
409
+ assertBridgeCapacity(estimatedBytes);
410
+
411
+ const responseId = state.nextHttpResponseId++;
412
+ state.httpResponses.set(responseId, {
413
+ status: response.status,
414
+ bodyText,
415
+ headers,
416
+ createdAtMs: Date.now(),
417
+ estimatedBytes,
418
+ });
419
+
420
+ log("[AresHost] stored responseId =", responseId);
421
+
422
+ return responseId >>> 0;
423
+ } catch (error) {
424
+ console.error("[AresHost] __aresAbiHttpFetchBlockingAsync failed:", error);
425
+ throw error;
426
+ }
427
+ },
428
+
429
+ __aresAbiHttpResponseGetStatus(responseId) {
430
+ return getResponseByIdOrEmpty(responseId).status >>> 0;
431
+ },
432
+
433
+ __aresAbiHttpResponseGetBodyLen(responseId) {
434
+ return utf8ByteLength(getResponseByIdOrEmpty(responseId).bodyText);
435
+ },
436
+
437
+ __aresAbiHttpResponseCopyBody(responseId, outPtr, maxLen) {
438
+ return copyUtf8ToHeap(
439
+ mod,
440
+ getResponseByIdOrEmpty(responseId).bodyText,
441
+ outPtr,
442
+ maxLen
443
+ );
444
+ },
445
+
446
+ __aresAbiHttpResponseCopyHeader(responseId, keyPtr, outPtr, maxLen) {
447
+ const key = readCStringFromModule(mod, keyPtr).toLowerCase();
448
+ const value = getResponseByIdOrEmpty(responseId).headers[key] ?? "";
449
+ return copyUtf8ToHeap(mod, value, outPtr, maxLen);
450
+ },
451
+
452
+ __aresAbiHttpResponseFree(responseId) {
453
+ state.httpResponses.delete(responseId);
454
+ },
455
+ });
456
+
457
+ currentInstance = mod;
458
+ state.instance = mod;
459
+
460
+ log("WASM module keys:", Object.keys(mod));
461
+ log("HEAPU8 exported:", mod.HEAPU8 instanceof Uint8Array);
462
+ log("UTF8ToString exported:", typeof mod.UTF8ToString === "function");
463
+ log("ccall exported:", typeof mod.ccall === "function");
464
+
465
+ getHeapU8(mod);
466
+
467
+ const appInit = pickExport(mod, ["_app_init", "app_init"]);
468
+ if (appInit) {
469
+ const rc = await Promise.resolve(appInit());
470
+ if (rc !== 0) {
471
+ throw new Error(`WASM app_init failed rc=${rc}`);
472
+ }
473
+ }
474
+
475
+ return mod;
476
+ })();
477
+
478
+ return instancePromise;
479
+ }
480
+
481
+ const ready = bootWasm();
482
+
483
+ async function handleFetch(request, env, ctx) {
484
+ let requestPtr = 0;
485
+ let requestSize = 0;
486
+ let responsePtr = 0;
487
+
488
+ try {
489
+ const inst = await ready;
490
+ const mod = getModule(inst);
491
+
492
+ state.requestContext = { request, env, ctx };
493
+
494
+ if (typeof mod.ccall !== "function") {
495
+ throw new Error("WASM runtime method ccall() is not available");
496
+ }
497
+
498
+ const bodyText = await request.text();
499
+ const url = new URL(request.url);
500
+
501
+ const inboundEnvelope = {
502
+ url: request.url,
503
+ path: url.pathname,
504
+ query: url.search,
505
+ method: request.method,
506
+ headers: headersToObject(request.headers),
507
+ body: bodyText,
508
+ };
509
+
510
+ const requestJson = JSON.stringify(inboundEnvelope);
511
+ const written = writeCString(mod, requestJson);
512
+ requestPtr = written.ptr;
513
+ requestSize = written.size;
514
+
515
+ responsePtr = await mod.ccall(
516
+ "handle_http",
517
+ "number",
518
+ ["number"],
519
+ [requestPtr],
520
+ { async: true }
521
+ );
522
+
523
+ log("[AresHost] ccall(handle_http) returned:", responsePtr);
524
+
525
+ if (!responsePtr) {
526
+ throw new Error("WASM returned null response pointer");
527
+ }
528
+
529
+ const responseJson = readCStringFromModule(mod, responsePtr);
530
+ const responseEnvelope = JSON.parse(responseJson);
531
+
532
+ return new Response(
533
+ typeof responseEnvelope.body === "string"
534
+ ? responseEnvelope.body
535
+ : JSON.stringify(responseEnvelope.body ?? ""),
536
+ {
537
+ status: responseEnvelope.status ?? 200,
538
+ headers: responseEnvelope.headers ?? {
539
+ "content-type": "application/json; charset=utf-8",
540
+ },
541
+ }
542
+ );
543
+ } catch (err) {
544
+ if (typeof state.options.onError === "function") {
545
+ try {
546
+ state.options.onError(err);
547
+ } catch {}
548
+ }
549
+
550
+ return jsonResponse(500, {
551
+ error: "worker_wasm_failure",
552
+ message: err instanceof Error ? err.message : String(err),
553
+ });
554
+ } finally {
555
+ state.requestContext = null;
556
+
557
+ try {
558
+ const inst = instancePromise
559
+ ? await instancePromise.catch(() => null)
560
+ : null;
561
+
562
+ if (inst) {
563
+ freePtr(inst, requestPtr, requestSize);
564
+ freePtr(inst, responsePtr);
565
+ }
566
+ } catch {
567
+ // ignore cleanup failures
568
+ }
569
+ }
570
+ }
571
+
572
+ return {
573
+ fetch: handleFetch,
574
+ ready: () => ready,
575
+ getModule: () => currentInstance,
576
+ getState: () => state,
577
+ };
578
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aresdefencelabs/wasm-http-runtime",
3
- "version": "0.1.4",
3
+ "version": "0.1.7",
4
4
  "description": "Runtime adapter that connects C++ WebAssembly workers to the Cloudflare Workers runtime via an ABI bridge.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -36,5 +36,5 @@
36
36
  },
37
37
  "main": "./dist/index.js",
38
38
  "types": "./dist/index.d.ts",
39
- "files": ["dist", "emscripten"]
39
+ "files": ["dist", "emscripten", "hostingEngine"]
40
40
  }