@query-farm/vgi-rpc-iroh-browser 0.24.1

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,723 @@
1
+ import { HttpiTransportError } from "./index.js";
2
+ const MAGIC = 0x42534756;
3
+ const VERSION = 1;
4
+ const HEADER_BYTES = 64;
5
+ const SLOT_CONTROL_BYTES = 64;
6
+ const HDR_MAGIC = 0;
7
+ const HDR_VERSION = 1;
8
+ const HDR_N_SLOTS = 2;
9
+ const HDR_RING_CAP = 3;
10
+ const HDR_SLOT_STRIDE = 4;
11
+ const HDR_SLOTS_OFF = 5;
12
+ const HDR_FEATURES = 6;
13
+ const STATE = 0;
14
+ const C2W_WRITE = 1;
15
+ const C2W_READ = 2;
16
+ const C2W_CLOSED = 3;
17
+ const W2C_WRITE = 4;
18
+ const W2C_READ = 5;
19
+ const W2C_CLOSED = 6;
20
+ const TERMINAL_CLAIM = 7;
21
+ const TERMINAL_CODE = 8;
22
+ const TERMINAL_DETAIL = 9;
23
+ const FEATURE_TERMINAL_ERROR = 1 << 0;
24
+ const ERROR_OPEN = 1;
25
+ const ERROR_CLIENT_TO_IROH = 2;
26
+ const ERROR_IROH_TO_CLIENT = 3;
27
+ const POLL_MS = 10;
28
+ const IO_CHUNK_BYTES = 64 * 1024;
29
+ const HTTPI_MAGIC = new Uint8Array([0x56, 0x47, 0x49, 0x48]);
30
+ const HTTPI_VERSION = 1;
31
+ const HTTPI_REQUEST = 1;
32
+ const HTTPI_RESPONSE = 2;
33
+ const HTTPI_RAW_REPRESENTATION = 1;
34
+ const HTTPI_TERMINAL_ONLY = 2;
35
+ const BODY_CHUNK = 1;
36
+ const BODY_END = 2;
37
+ const BODY_TERMINAL = 3;
38
+ const MAX_HEADERS = 1024;
39
+ const MAX_HEADER_BYTES = 1024 * 1024;
40
+ const DEFAULT_MAX_REQUEST_BYTES = 64 * 1024 * 1024;
41
+ const DEFAULT_MAX_AGGREGATE_REQUEST_BYTES = 128 * 1024 * 1024;
42
+ const MAX_DETAIL_BYTES = 512;
43
+ const STAGE_PARSE = 1;
44
+ const STAGE_RESOLVE = 2;
45
+ const STAGE_CONNECT = 3;
46
+ const STAGE_REQUEST = 4;
47
+ const STAGE_RESPONSE_HEAD = 5;
48
+ const STAGE_RESPONSE_BODY = 6;
49
+ const CATEGORY_INVALID_REQUEST = 1;
50
+ const CATEGORY_UNAUTHORIZED_TARGET = 2;
51
+ const CATEGORY_UNAVAILABLE = 3;
52
+ const CATEGORY_TIMEOUT = 4;
53
+ const CATEGORY_CANCELLED = 5;
54
+ const CATEGORY_PROTOCOL = 6;
55
+ const CATEGORY_TRANSPORT = 7;
56
+ const CATEGORY_INTERNAL = 8;
57
+ const DISPATCH_NOT_DISPATCHED = 1;
58
+ const DISPATCH_DISPATCHED = 2;
59
+ const DISPATCH_AMBIGUOUS = 3;
60
+ class RequestBodyBudget {
61
+ perRequest;
62
+ aggregate;
63
+ used = 0;
64
+ constructor(perRequest, aggregate) {
65
+ this.perRequest = perRequest;
66
+ this.aggregate = aggregate;
67
+ }
68
+ reserve(current, additional) {
69
+ if (current + additional > this.perRequest)
70
+ throw new Error("HTTPI request body exceeds per-request limit");
71
+ if (this.used + additional > this.aggregate)
72
+ throw new Error("HTTPI aggregate request-body budget exhausted");
73
+ this.used += additional;
74
+ }
75
+ release(bytes) {
76
+ this.used = Math.max(0, this.used - bytes);
77
+ }
78
+ }
79
+ function delay(milliseconds) {
80
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
81
+ }
82
+ async function waitForChange(words, index, value) {
83
+ const waitAsync = Atomics.waitAsync;
84
+ if (waitAsync) {
85
+ const result = waitAsync(words, index, value, 250);
86
+ if (result.async)
87
+ await result.value;
88
+ return;
89
+ }
90
+ await delay(POLL_MS);
91
+ }
92
+ function slotWord(region, slot) {
93
+ return (region.base + region.slotsOffset + slot * region.stride) >> 2;
94
+ }
95
+ function claimStillOwned(region, slot, claim, signal) {
96
+ return (!signal?.aborted &&
97
+ !region.stopped &&
98
+ Atomics.load(region.words, slotWord(region, slot) + STATE) === claim);
99
+ }
100
+ function copyRingOut(bytes, dataOffset, ringCapacity, position, length) {
101
+ const output = new Uint8Array(length);
102
+ const start = position % ringCapacity;
103
+ const first = Math.min(length, ringCapacity - start);
104
+ output.set(bytes.subarray(dataOffset + start, dataOffset + start + first));
105
+ if (length > first)
106
+ output.set(bytes.subarray(dataOffset, dataOffset + length - first), first);
107
+ return output;
108
+ }
109
+ function copyRingIn(bytes, dataOffset, ringCapacity, position, source, sourceOffset, length) {
110
+ const start = position % ringCapacity;
111
+ const first = Math.min(length, ringCapacity - start);
112
+ bytes.set(source.subarray(sourceOffset, sourceOffset + first), dataOffset + start);
113
+ if (length > first) {
114
+ bytes.set(source.subarray(sourceOffset + first, sourceOffset + length), dataOffset);
115
+ }
116
+ }
117
+ async function readClientChunk(region, slot, claim, signal) {
118
+ const control = slotWord(region, slot);
119
+ const dataOffset = (control << 2) + SLOT_CONTROL_BYTES;
120
+ for (;;) {
121
+ if (!claimStillOwned(region, slot, claim, signal))
122
+ return undefined;
123
+ const write = Atomics.load(region.words, control + C2W_WRITE);
124
+ const read = Atomics.load(region.words, control + C2W_READ);
125
+ const available = write - read;
126
+ if (available > 0) {
127
+ const length = Math.min(available, IO_CHUNK_BYTES);
128
+ const chunk = copyRingOut(region.bytes, dataOffset, region.ringCap, read, length);
129
+ Atomics.store(region.words, control + C2W_READ, read + length);
130
+ Atomics.notify(region.words, control + C2W_READ);
131
+ return chunk;
132
+ }
133
+ if (Atomics.load(region.words, control + C2W_CLOSED) !== 0)
134
+ return undefined;
135
+ await waitForChange(region.words, control + C2W_WRITE, write);
136
+ }
137
+ }
138
+ async function writeWorkerChunk(region, slot, claim, chunk, signal) {
139
+ const control = slotWord(region, slot);
140
+ const dataOffset = (control << 2) + SLOT_CONTROL_BYTES + region.ringCap;
141
+ let offset = 0;
142
+ while (offset < chunk.length) {
143
+ if (!claimStillOwned(region, slot, claim, signal))
144
+ return false;
145
+ const write = Atomics.load(region.words, control + W2C_WRITE);
146
+ const read = Atomics.load(region.words, control + W2C_READ);
147
+ const free = region.ringCap - (write - read);
148
+ if (free === 0) {
149
+ await waitForChange(region.words, control + W2C_READ, read);
150
+ continue;
151
+ }
152
+ const length = Math.min(free, chunk.length - offset);
153
+ copyRingIn(region.bytes, dataOffset, region.ringCap, write, chunk, offset, length);
154
+ Atomics.store(region.words, control + W2C_WRITE, write + length);
155
+ Atomics.notify(region.words, control + W2C_WRITE);
156
+ offset += length;
157
+ }
158
+ return true;
159
+ }
160
+ class RingReader {
161
+ // Chunks may be backed by either an ordinary ArrayBuffer (Iroh/Web streams)
162
+ // or the SharedArrayBuffer ring. Keep the backing type honest at this
163
+ // boundary; TypeScript 6 no longer silently treats the two as identical.
164
+ pending = new Uint8Array(0);
165
+ region;
166
+ slot;
167
+ claim;
168
+ signal;
169
+ constructor(region, slot, claim, signal) {
170
+ this.region = region;
171
+ this.slot = slot;
172
+ this.claim = claim;
173
+ this.signal = signal;
174
+ }
175
+ async exact(length) {
176
+ if (!Number.isSafeInteger(length) || length < 0)
177
+ throw new Error("invalid envelope length");
178
+ const output = new Uint8Array(length);
179
+ let offset = 0;
180
+ while (offset < length) {
181
+ if (this.pending.length === 0) {
182
+ const chunk = await readClientChunk(this.region, this.slot, this.claim, this.signal);
183
+ if (chunk === undefined)
184
+ throw new Error("request envelope ended early");
185
+ this.pending = chunk;
186
+ }
187
+ const take = Math.min(length - offset, this.pending.length);
188
+ output.set(this.pending.subarray(0, take), offset);
189
+ this.pending = this.pending.subarray(take);
190
+ offset += take;
191
+ }
192
+ return output;
193
+ }
194
+ }
195
+ function u16(bytes, offset) {
196
+ return bytes[offset] | (bytes[offset + 1] << 8);
197
+ }
198
+ function u32(bytes, offset) {
199
+ return ((bytes[offset] |
200
+ (bytes[offset + 1] << 8) |
201
+ (bytes[offset + 2] << 16) |
202
+ (bytes[offset + 3] << 24)) >>>
203
+ 0);
204
+ }
205
+ function putU16(view, offset, value) {
206
+ view.setUint16(offset, value, true);
207
+ }
208
+ function putU32(view, offset, value) {
209
+ view.setUint32(offset, value, true);
210
+ }
211
+ function responseHead(status, headers, terminalOnly = false) {
212
+ const encoder = new TextEncoder();
213
+ const encoded = headers.map(([name, value]) => [encoder.encode(name), encoder.encode(value)]);
214
+ const headerBytes = encoded.reduce((sum, [name, value]) => sum + name.length + value.length, 0);
215
+ if (headers.length > MAX_HEADERS || headerBytes > MAX_HEADER_BYTES) {
216
+ throw new Error("response headers exceed HTTPI envelope limits");
217
+ }
218
+ const bytes = new Uint8Array(16 +
219
+ encoded.reduce((sum, [name, value]) => sum + 8 + name.length + value.length, 0));
220
+ bytes.set(HTTPI_MAGIC, 0);
221
+ bytes[4] = HTTPI_VERSION;
222
+ bytes[5] = HTTPI_RESPONSE;
223
+ const view = new DataView(bytes.buffer);
224
+ putU16(view, 6, HTTPI_RAW_REPRESENTATION | (terminalOnly ? HTTPI_TERMINAL_ONLY : 0));
225
+ putU16(view, 8, status);
226
+ putU16(view, 10, 0);
227
+ putU32(view, 12, headers.length);
228
+ let offset = 16;
229
+ for (const [name, value] of encoded) {
230
+ putU32(view, offset, name.length);
231
+ putU32(view, offset + 4, value.length);
232
+ offset += 8;
233
+ bytes.set(name, offset);
234
+ offset += name.length;
235
+ bytes.set(value, offset);
236
+ offset += value.length;
237
+ }
238
+ return bytes;
239
+ }
240
+ function frame(kind, payload = new Uint8Array(0), stage = 0, category = 0, certainty = 0) {
241
+ const bytes = new Uint8Array(8 + payload.length);
242
+ bytes[0] = kind;
243
+ bytes[1] = stage;
244
+ bytes[2] = category;
245
+ bytes[3] = certainty;
246
+ putU32(new DataView(bytes.buffer), 4, payload.length);
247
+ bytes.set(payload, 8);
248
+ return bytes;
249
+ }
250
+ function sanitizedDetail(error) {
251
+ const text = (error instanceof Error ? error.message : String(error)).replace(/[\u0000-\u001f\u007f]/g, " ");
252
+ const encoded = new TextEncoder().encode(text);
253
+ return encoded.length <= MAX_DETAIL_BYTES
254
+ ? encoded
255
+ : encoded.slice(0, MAX_DETAIL_BYTES);
256
+ }
257
+ async function terminal(region, slot, claim, stage, category, certainty, error, includeHead, signal) {
258
+ if (includeHead &&
259
+ !(await writeWorkerChunk(region, slot, claim, responseHead(0, [], true), signal)))
260
+ return;
261
+ await writeWorkerChunk(region, slot, claim, frame(BODY_TERMINAL, sanitizedDetail(error), stage, category, certainty), signal);
262
+ closeWorkerOutput(region, slot, claim);
263
+ }
264
+ async function readHttpiRequest(reader, budget) {
265
+ let reservedBytes = 0;
266
+ try {
267
+ const prefix = await reader.exact(20);
268
+ if (!HTTPI_MAGIC.every((value, index) => prefix[index] === value) ||
269
+ prefix[4] !== HTTPI_VERSION ||
270
+ prefix[5] !== HTTPI_REQUEST ||
271
+ u16(prefix, 6) !== 0) {
272
+ throw new Error("invalid HTTPI request envelope");
273
+ }
274
+ const methodLength = u16(prefix, 8);
275
+ const pathLength = u32(prefix, 12);
276
+ const headerCount = u32(prefix, 16);
277
+ if (methodLength === 0 ||
278
+ methodLength > 16 ||
279
+ pathLength === 0 ||
280
+ pathLength > 16 * 1024 ||
281
+ headerCount > MAX_HEADERS) {
282
+ throw new Error("HTTPI request head exceeds limits");
283
+ }
284
+ const decoder = new TextDecoder("utf-8", { fatal: true });
285
+ const method = decoder.decode(await reader.exact(methodLength));
286
+ const path = decoder.decode(await reader.exact(pathLength));
287
+ if (!/^[A-Z]+$/.test(method) ||
288
+ !/^\/(?:[A-Za-z0-9._~!$&'()*+,;=:@-]+(?:\/[A-Za-z0-9._~!$&'()*+,;=:@-]+)*)?$/.test(path)) {
289
+ throw new Error("invalid HTTPI method or path");
290
+ }
291
+ const headers = [];
292
+ let headerBytes = 0;
293
+ for (let i = 0; i < headerCount; i++) {
294
+ const lengths = await reader.exact(8);
295
+ const nameLength = u32(lengths, 0);
296
+ const valueLength = u32(lengths, 4);
297
+ headerBytes += nameLength + valueLength;
298
+ if (nameLength === 0 || headerBytes > MAX_HEADER_BYTES)
299
+ throw new Error("HTTPI request headers exceed limits");
300
+ const name = decoder.decode(await reader.exact(nameLength));
301
+ const value = decoder.decode(await reader.exact(valueLength));
302
+ if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name) ||
303
+ /[\u0000-\u0008\u000a-\u001f\u007f]/.test(value)) {
304
+ throw new Error("invalid HTTPI header");
305
+ }
306
+ headers.push([name, value]);
307
+ }
308
+ const chunks = [];
309
+ let bodyBytes = 0;
310
+ for (;;) {
311
+ const head = await reader.exact(8);
312
+ const length = u32(head, 4);
313
+ if (head[0] === BODY_END && length === 0)
314
+ break;
315
+ if (head[0] !== BODY_CHUNK ||
316
+ head[1] !== 0 ||
317
+ head[2] !== 0 ||
318
+ head[3] !== 0 ||
319
+ length > IO_CHUNK_BYTES) {
320
+ throw new Error("invalid HTTPI request body frame");
321
+ }
322
+ budget.reserve(bodyBytes, length);
323
+ reservedBytes += length;
324
+ bodyBytes += length;
325
+ chunks.push(await reader.exact(length));
326
+ }
327
+ const body = new Uint8Array(bodyBytes);
328
+ let bodyOffset = 0;
329
+ for (const chunk of chunks) {
330
+ body.set(chunk, bodyOffset);
331
+ bodyOffset += chunk.length;
332
+ }
333
+ return { method, path, headers, body, reservedBytes };
334
+ }
335
+ catch (error) {
336
+ budget.release(reservedBytes);
337
+ throw error;
338
+ }
339
+ }
340
+ function closeWorkerOutput(region, slot, claim) {
341
+ if (!claimStillOwned(region, slot, claim))
342
+ return;
343
+ const control = slotWord(region, slot);
344
+ Atomics.store(region.words, control + W2C_CLOSED, claim);
345
+ Atomics.notify(region.words, control + W2C_WRITE);
346
+ }
347
+ function closeWorkerError(region, slot, claim, code) {
348
+ if (!claimStillOwned(region, slot, claim))
349
+ return;
350
+ const control = slotWord(region, slot);
351
+ if ((Atomics.load(region.words, (region.base >> 2) + HDR_FEATURES) &
352
+ FEATURE_TERMINAL_ERROR) !==
353
+ 0) {
354
+ Atomics.store(region.words, control + TERMINAL_CODE, code);
355
+ Atomics.store(region.words, control + TERMINAL_DETAIL, 0);
356
+ Atomics.store(region.words, control + TERMINAL_CLAIM, claim);
357
+ }
358
+ Atomics.store(region.words, control + W2C_CLOSED, claim);
359
+ Atomics.notify(region.words, control + W2C_WRITE);
360
+ }
361
+ async function pumpClientToIroh(region, slot, claim, stream, signal) {
362
+ const writer = stream.writable.getWriter();
363
+ try {
364
+ for (;;) {
365
+ const chunk = await readClientChunk(region, slot, claim, signal);
366
+ if (chunk === undefined) {
367
+ if (signal.aborted)
368
+ throw signal.reason ?? new Error("raw Iroh pump cancelled");
369
+ break;
370
+ }
371
+ if (signal.aborted)
372
+ throw signal.reason ?? new Error("raw Iroh pump cancelled");
373
+ await writer.write(chunk);
374
+ }
375
+ await writer.close();
376
+ }
377
+ finally {
378
+ writer.releaseLock();
379
+ }
380
+ }
381
+ async function pumpIrohToClient(region, slot, claim, stream, signal) {
382
+ const reader = stream.readable.getReader();
383
+ try {
384
+ for (;;) {
385
+ const result = await reader.read();
386
+ if (result.done)
387
+ break;
388
+ if (!(await writeWorkerChunk(region, slot, claim, result.value, signal))) {
389
+ if (signal.aborted)
390
+ throw signal.reason ?? new Error("raw Iroh pump cancelled");
391
+ break;
392
+ }
393
+ if (signal.aborted)
394
+ throw signal.reason ?? new Error("raw Iroh pump cancelled");
395
+ }
396
+ }
397
+ finally {
398
+ reader.releaseLock();
399
+ }
400
+ }
401
+ async function serveClaim(node, region, slot, claim, signal) {
402
+ let stream;
403
+ try {
404
+ stream = await node.openVgiStream(region.endpointId, { signal });
405
+ }
406
+ catch {
407
+ if (!signal.aborted)
408
+ closeWorkerError(region, slot, claim, ERROR_OPEN);
409
+ return;
410
+ }
411
+ const pumps = new AbortController();
412
+ const cancelPumps = () => {
413
+ pumps.abort(signal.reason ?? new Error("Iroh claim cancelled"));
414
+ stream.abort(signal.reason);
415
+ };
416
+ signal.addEventListener("abort", cancelPumps, { once: true });
417
+ let published = false;
418
+ const fail = (code, error) => {
419
+ if (published)
420
+ return;
421
+ published = true;
422
+ pumps.abort(error);
423
+ stream.abort(error);
424
+ closeWorkerError(region, slot, claim, code);
425
+ };
426
+ const input = pumpClientToIroh(region, slot, claim, stream, pumps.signal).catch((error) => {
427
+ fail(ERROR_CLIENT_TO_IROH, error);
428
+ throw error;
429
+ });
430
+ const output = pumpIrohToClient(region, slot, claim, stream, pumps.signal).catch((error) => {
431
+ fail(ERROR_IROH_TO_CLIENT, error);
432
+ throw error;
433
+ });
434
+ const [inputResult, outputResult] = await Promise.allSettled([input, output]);
435
+ signal.removeEventListener("abort", cancelPumps);
436
+ if (!published &&
437
+ inputResult.status === "fulfilled" &&
438
+ outputResult.status === "fulfilled" &&
439
+ !signal.aborted) {
440
+ closeWorkerOutput(region, slot, claim);
441
+ }
442
+ }
443
+ async function serveHttpiClaim(node, region, slot, claim, signal, budget) {
444
+ let request;
445
+ try {
446
+ request = await readHttpiRequest(new RingReader(region, slot, claim, signal), budget);
447
+ }
448
+ catch (error) {
449
+ if (signal.aborted)
450
+ return;
451
+ await terminal(region, slot, claim, STAGE_PARSE, CATEGORY_INVALID_REQUEST, DISPATCH_NOT_DISPATCHED, error, true, signal);
452
+ return;
453
+ }
454
+ let response;
455
+ let underlyingSettled;
456
+ try {
457
+ response = await node.fetchHttpi(region.endpointId, request.method, request.path, request.headers, request.body, signal, (settled) => {
458
+ underlyingSettled = settled;
459
+ });
460
+ }
461
+ catch (error) {
462
+ if (signal.aborted)
463
+ return;
464
+ const structured = error instanceof HttpiTransportError ? error : undefined;
465
+ const stages = {
466
+ parse: STAGE_PARSE,
467
+ resolve: STAGE_RESOLVE,
468
+ connect: STAGE_CONNECT,
469
+ request: STAGE_REQUEST,
470
+ response_head: STAGE_RESPONSE_HEAD,
471
+ response_body: STAGE_RESPONSE_BODY,
472
+ };
473
+ const categories = {
474
+ invalid_request: CATEGORY_INVALID_REQUEST,
475
+ unauthorized_target: CATEGORY_UNAUTHORIZED_TARGET,
476
+ unavailable: CATEGORY_UNAVAILABLE,
477
+ timeout: CATEGORY_TIMEOUT,
478
+ cancelled: CATEGORY_CANCELLED,
479
+ protocol: CATEGORY_PROTOCOL,
480
+ transport: CATEGORY_TRANSPORT,
481
+ internal: CATEGORY_INTERNAL,
482
+ };
483
+ const certainties = {
484
+ not_dispatched: DISPATCH_NOT_DISPATCHED,
485
+ dispatched: DISPATCH_DISPATCHED,
486
+ ambiguous: DISPATCH_AMBIGUOUS,
487
+ };
488
+ const stage = structured ? stages[structured.stage] : STAGE_REQUEST;
489
+ const category = structured
490
+ ? categories[structured.category]
491
+ : CATEGORY_TRANSPORT;
492
+ const certainty = structured
493
+ ? certainties[structured.dispatchCertainty]
494
+ : DISPATCH_AMBIGUOUS;
495
+ await terminal(region, slot, claim, stage, category, certainty, error, true, signal);
496
+ return;
497
+ }
498
+ finally {
499
+ const release = () => budget.release(request.reservedBytes);
500
+ if (underlyingSettled)
501
+ void underlyingSettled.then(release, release);
502
+ else
503
+ release();
504
+ request.body = new Uint8Array();
505
+ }
506
+ try {
507
+ if (response.bodyEncoding !== "raw")
508
+ throw new Error("HTTPI response is not raw representation bytes");
509
+ if (!Number.isInteger(response.status) ||
510
+ response.status < 100 ||
511
+ response.status > 999) {
512
+ throw new Error("invalid HTTPI response status");
513
+ }
514
+ if (!(await writeWorkerChunk(region, slot, claim, responseHead(response.status, response.headers), signal))) {
515
+ await response.body.cancel("VGI HTTPI SAB slot released before response head delivery");
516
+ return;
517
+ }
518
+ }
519
+ catch (error) {
520
+ await response.body.cancel("VGI HTTPI rejected response head");
521
+ await terminal(region, slot, claim, STAGE_RESPONSE_HEAD, CATEGORY_PROTOCOL, DISPATCH_DISPATCHED, error, true, signal);
522
+ return;
523
+ }
524
+ const reader = response.body.getReader();
525
+ try {
526
+ for (;;) {
527
+ const result = await reader.read();
528
+ if (result.done)
529
+ break;
530
+ if (result.value.length > IO_CHUNK_BYTES) {
531
+ for (let offset = 0; offset < result.value.length; offset += IO_CHUNK_BYTES) {
532
+ if (!(await writeWorkerChunk(region, slot, claim, frame(BODY_CHUNK, result.value.subarray(offset, offset + IO_CHUNK_BYTES)), signal))) {
533
+ await reader.cancel("VGI HTTPI SAB slot released during response body");
534
+ return;
535
+ }
536
+ }
537
+ }
538
+ else if (!(await writeWorkerChunk(region, slot, claim, frame(BODY_CHUNK, result.value), signal))) {
539
+ await reader.cancel("VGI HTTPI SAB slot released during response body");
540
+ return;
541
+ }
542
+ }
543
+ await writeWorkerChunk(region, slot, claim, frame(BODY_END), signal);
544
+ closeWorkerOutput(region, slot, claim);
545
+ }
546
+ catch (error) {
547
+ await terminal(region, slot, claim, STAGE_RESPONSE_BODY, CATEGORY_TRANSPORT, DISPATCH_DISPATCHED, error, false, signal);
548
+ }
549
+ finally {
550
+ reader.releaseLock();
551
+ }
552
+ }
553
+ function parseRegion(message) {
554
+ const rawMatch = /^iroh:\/\/([0-9a-f]{64})$/.exec(message.target);
555
+ const httpiMatch = /^httpi:\/\/([0-9a-f]{64})(?:\/(?:[A-Za-z0-9._~!$&'()*+,;=:@-]+(?:\/[A-Za-z0-9._~!$&'()*+,;=:@-]+)*))?$/.exec(message.target);
556
+ const match = rawMatch ?? httpiMatch;
557
+ if (!match)
558
+ throw new Error("adapter target must be a canonical iroh:// or httpi:// EndpointId target");
559
+ if (!(message.buffer instanceof SharedArrayBuffer))
560
+ throw new Error("adapter buffer must be shared");
561
+ if (!Number.isSafeInteger(message.offset) ||
562
+ message.offset < 0 ||
563
+ (message.offset & 3) !== 0) {
564
+ throw new Error("adapter region offset must be a non-negative aligned integer");
565
+ }
566
+ const words = new Int32Array(message.buffer);
567
+ const header = message.offset >> 2;
568
+ const nSlots = Atomics.load(words, header + HDR_N_SLOTS);
569
+ const ringCap = Atomics.load(words, header + HDR_RING_CAP);
570
+ const stride = Atomics.load(words, header + HDR_SLOT_STRIDE);
571
+ const slotsOffset = Atomics.load(words, header + HDR_SLOTS_OFF);
572
+ if (Atomics.load(words, header + HDR_MAGIC) !== MAGIC ||
573
+ Atomics.load(words, header + HDR_VERSION) !== VERSION ||
574
+ nSlots <= 0 ||
575
+ nSlots > 1024 ||
576
+ ringCap <= 0 ||
577
+ stride < SLOT_CONTROL_BYTES + ringCap * 2 ||
578
+ slotsOffset < HEADER_BYTES ||
579
+ message.offset + slotsOffset + nSlots * stride > message.buffer.byteLength) {
580
+ throw new Error("invalid VGI SAB region header");
581
+ }
582
+ return {
583
+ target: message.target,
584
+ endpointId: match[1],
585
+ protocol: rawMatch ? "raw" : "httpi",
586
+ buffer: message.buffer,
587
+ bytes: new Uint8Array(message.buffer),
588
+ words,
589
+ base: message.offset,
590
+ nSlots,
591
+ ringCap,
592
+ stride,
593
+ slotsOffset,
594
+ running: new Map(),
595
+ stopped: false,
596
+ };
597
+ }
598
+ /**
599
+ * Install the complete SAB-to-Iroh mux pump in an application-owned Worker.
600
+ * Returns a local teardown hook for tests or an application-controlled Worker
601
+ * shutdown sequence; it does not close the application-owned Iroh node.
602
+ */
603
+ export function installIrohVgiAdapter(nodePromise, options = {}) {
604
+ const regions = new Map();
605
+ let node;
606
+ let polling = false;
607
+ let installed = true;
608
+ const perRequest = options.maxHttpiRequestBytes ?? DEFAULT_MAX_REQUEST_BYTES;
609
+ const aggregate = options.maxHttpiAggregateRequestBytes ??
610
+ DEFAULT_MAX_AGGREGATE_REQUEST_BYTES;
611
+ if (!Number.isSafeInteger(perRequest) ||
612
+ perRequest <= 0 ||
613
+ !Number.isSafeInteger(aggregate) ||
614
+ aggregate < perRequest) {
615
+ throw new RangeError("HTTPI request limits must be positive safe integers and aggregate >= per-request");
616
+ }
617
+ const bodyBudget = new RequestBodyBudget(perRequest, aggregate);
618
+ const cancelActive = (region, reason) => {
619
+ region.stopped = true;
620
+ for (const [slot, active] of region.running) {
621
+ active.controller.abort(reason);
622
+ const control = slotWord(region, slot);
623
+ Atomics.notify(region.words, control + C2W_WRITE);
624
+ Atomics.notify(region.words, control + W2C_READ);
625
+ }
626
+ region.running.clear();
627
+ };
628
+ const poll = async () => {
629
+ if (polling || !node)
630
+ return;
631
+ polling = true;
632
+ try {
633
+ while (installed) {
634
+ let active = false;
635
+ for (const region of regions.values()) {
636
+ if (region.stopped)
637
+ continue;
638
+ for (let slot = 0; slot < region.nSlots; slot++) {
639
+ const claim = Atomics.load(region.words, slotWord(region, slot) + STATE);
640
+ const lastClaim = region.running.get(slot);
641
+ if (claim === 0) {
642
+ if (lastClaim !== undefined) {
643
+ lastClaim.controller.abort(new Error("VGI SAB claim released"));
644
+ const control = slotWord(region, slot);
645
+ Atomics.notify(region.words, control + C2W_WRITE);
646
+ Atomics.notify(region.words, control + W2C_READ);
647
+ region.running.delete(slot);
648
+ }
649
+ continue;
650
+ }
651
+ active = true;
652
+ if (!lastClaim || lastClaim.claim !== claim) {
653
+ lastClaim?.controller.abort(new Error("VGI SAB claim replaced"));
654
+ const controller = new AbortController();
655
+ region.running.set(slot, { claim, controller });
656
+ // Retain the completed claim marker until STATE changes. A
657
+ // failed open publishes one terminal error and must not redial
658
+ // the same claim in a tight loop before the client releases it.
659
+ void (region.protocol === "httpi"
660
+ ? serveHttpiClaim(node, region, slot, claim, controller.signal, bodyBudget)
661
+ : serveClaim(node, region, slot, claim, controller.signal));
662
+ }
663
+ }
664
+ }
665
+ await delay(active ? 1 : POLL_MS);
666
+ }
667
+ }
668
+ finally {
669
+ polling = false;
670
+ }
671
+ };
672
+ const onMessage = (event) => {
673
+ const message = event.data;
674
+ if (!message || typeof message !== "object")
675
+ return;
676
+ if (message.type === "vgi-unregister-target") {
677
+ const region = regions.get(message.target);
678
+ if (region && region.base === message.offset) {
679
+ cancelActive(region, new Error("VGI target unregistered"));
680
+ regions.delete(message.target);
681
+ }
682
+ return;
683
+ }
684
+ if (message.type !== "vgi-init" && message.type !== "vgi-register-target")
685
+ return;
686
+ void nodePromise.then((resolvedNode) => {
687
+ node = resolvedNode;
688
+ const region = parseRegion(message);
689
+ const old = regions.get(region.target);
690
+ if (old)
691
+ cancelActive(old, new Error("VGI target region replaced"));
692
+ regions.set(region.target, region);
693
+ void poll();
694
+ if (message.type === "vgi-init") {
695
+ self.postMessage({ type: "vgi-ready", endpointId: node.endpointId });
696
+ }
697
+ else {
698
+ self.postMessage({
699
+ type: "vgi-target-ready",
700
+ requestId: message.requestId,
701
+ });
702
+ }
703
+ }, (error) => {
704
+ const detail = error instanceof Error ? error.message : String(error);
705
+ if (message.type === "vgi-init")
706
+ self.postMessage({ type: "vgi-error", error: detail });
707
+ else
708
+ self.postMessage({
709
+ type: "vgi-target-error",
710
+ requestId: message.requestId,
711
+ error: detail,
712
+ });
713
+ });
714
+ };
715
+ self.addEventListener("message", onMessage);
716
+ return () => {
717
+ installed = false;
718
+ for (const region of regions.values())
719
+ cancelActive(region, new Error("Iroh adapter stopped"));
720
+ regions.clear();
721
+ self.removeEventListener("message", onMessage);
722
+ };
723
+ }