@volter/tabnode 0.5.26 → 0.5.27

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.
@@ -8,41 +8,15 @@ import {
8
8
  type ResponseData, type LoopbackStreamFlow,
9
9
  } from './node-lib/http-bridge';
10
10
  import { setPortWatchers } from './node-lib/net-module';
11
- import { EventEmitter } from './node-lib/events-module';
12
11
  import { __bridgeConnection } from './node-lib/net-module';
13
12
  import type { Socket } from './node-lib/net-module';
14
13
  import { Buffer } from './node-lib/buffer-module';
15
14
  import { uint8ToBase64 } from './utils/binary-encoding';
15
+ import { PortBridge, type IVirtualServer, type VirtualServer, type BridgeOptions } from './port-bridge';
16
16
 
17
17
  const _encoder = new TextEncoder();
18
- // The worker's flow-controlled stream: a request body up to this size, and a
19
- // response in chunks of exactly the worker's own maximum, one per pull.
20
- const FLOW_MAX_REQUEST_BYTES = 64 * 1024 * 1024;
21
- const FLOW_MAX_CHUNK_BYTES = 65536;
22
- // Chunks that may wait for the reader's credit before the server is paused.
23
- const FLOW_QUEUED_CHUNKS = 4;
24
18
 
25
- /** The bytes this view names, in a buffer of their own. A pooled Buffer is a window on 8 KB. */
26
- function ownedBytes(view: Uint8Array): Uint8Array {
27
- const copy = new Uint8Array(view.byteLength);
28
- copy.set(new Uint8Array(view.buffer, view.byteOffset, view.byteLength));
29
- return copy;
30
- }
31
-
32
- /**
33
- * Interface for virtual servers that can be registered with the bridge
34
- */
35
- export interface IVirtualServer {
36
- listening: boolean;
37
- /** What `net.Server.address()` answers: an address and a port, or the PATH of a unix-domain socket. */
38
- address(): { port: number; address: string; family: string } | string | null;
39
- handleRequest(
40
- method: string,
41
- url: string,
42
- headers: Record<string, string>,
43
- body?: Buffer | string
44
- ): Promise<ResponseData>;
45
- }
19
+ export type { IVirtualServer, VirtualServer, BridgeOptions, InitServiceWorkerOptions } from './port-bridge';
46
20
 
47
21
  /**
48
22
  * A guest that called `listen` is a server: it answers requests over the
@@ -62,39 +36,11 @@ function guestListeningServer(port: number, address: string): IVirtualServer {
62
36
  family: hostname.includes(':') ? 'IPv6' : 'IPv4',
63
37
  }),
64
38
  handleRequest: (method, url, headers, body) =>
65
- __requestOverLoopback(port, method, url, headers, typeof body === 'string' ? Buffer.from(body) : body),
39
+ // this engine's bridge hands its servers a Buffer (`bodyOf`)
40
+ __requestOverLoopback(port, method, url, headers, (typeof body === 'string' ? Buffer.from(body) : body) as Buffer | undefined),
66
41
  };
67
42
  }
68
43
 
69
- export interface VirtualServer {
70
- /**
71
- * A server the host registered and answers itself, or null for a guest's
72
- * own: a guest's server is a port this engine is listening on, and it is
73
- * reached by connecting to it like any other client.
74
- */
75
- server: IVirtualServer | null;
76
- port: number;
77
- hostname: string;
78
- }
79
-
80
- export interface BridgeOptions {
81
- baseUrl?: string;
82
- onServerReady?: (port: number, url: string) => void;
83
- }
84
-
85
- export interface InitServiceWorkerOptions {
86
- /**
87
- * The URL path to the service worker file
88
- * @default '/__sw__.js'
89
- */
90
- swUrl?: string;
91
- /**
92
- * The page's own documents that are frames of it (a sandbox, a worker
93
- * host), by path, so the worker does not take one for the preview's.
94
- */
95
- ownDocuments?: string[];
96
- }
97
-
98
44
  /**
99
45
  * Server Bridge manages virtual HTTP servers and routes requests
100
46
  */
@@ -393,38 +339,17 @@ function __substrateListenForUpgrades(bridge: ServerBridge): BroadcastChannel |
393
339
  return channel;
394
340
  }
395
341
 
396
- export class ServerBridge extends EventEmitter {
397
- static DEBUG = false;
398
- servers: Map<number, VirtualServer> = new Map();
399
- private baseUrl: string;
400
- private options: BridgeOptions;
401
- private messageChannel: MessageChannel | null = null;
342
+ export class ServerBridge extends PortBridge {
402
343
  /** The upgrade channel this bridge opened, held so it can be given back. */
403
344
  private upgradeChannel: BroadcastChannel | null = null;
404
- private serviceWorkerReady: boolean = false;
405
- private keepaliveInterval: ReturnType<typeof setInterval> | null = null;
406
345
 
407
346
  constructor(options: BridgeOptions = {}) {
408
- super();
409
- this.options = options;
410
-
411
- // Handle browser vs Node.js environment
412
- if (typeof location !== 'undefined') {
413
- this.baseUrl = options.baseUrl || `${location.protocol}//${location.host}`;
414
- } else {
415
- this.baseUrl = options.baseUrl || 'http://localhost';
416
- }
417
-
347
+ super(options);
418
348
  // A guest's servers register themselves, from the port they take.
419
349
  this.watchGuestPorts();
420
350
  this.upgradeChannel = __substrateListenForUpgrades(this);
421
351
  }
422
352
 
423
- /**
424
- * Give back everything this bridge opened: the upgrade channel and the
425
- * service worker keepalive. A host that is done with a container calls it
426
- * and has its process back; calling it twice is nothing.
427
- */
428
353
  /**
429
354
  * Every port a guest starts listening on becomes a server this bridge can
430
355
  * answer for, and every port it stops listening on stops being one. The
@@ -455,603 +380,36 @@ export class ServerBridge extends EventEmitter {
455
380
  );
456
381
  }
457
382
 
458
- close(): void {
383
+ override close(): void {
459
384
  if (this.upgradeChannel) {
460
385
  try { this.upgradeChannel.close(); } catch { /* a channel the realm already closed is closed */ }
461
386
  this.upgradeChannel = null;
462
387
  }
463
- if (this.keepaliveInterval !== null) {
464
- clearInterval(this.keepaliveInterval);
465
- this.keepaliveInterval = null;
466
- }
388
+ super.close();
467
389
  }
468
390
 
469
- /**
470
- * Register a server on a port
471
- */
472
- registerServer(server: IVirtualServer | null, port: number, hostname: string = '0.0.0.0'): void {
473
- this.servers.set(port, { server, port, hostname });
474
-
475
- // Emit server-ready event
476
- const url = this.getServerUrl(port);
477
- this.emit('server-ready', port, url);
478
-
479
- if (this.options.onServerReady) {
480
- this.options.onServerReady(port, url);
481
- }
482
-
483
- // Notify service worker if connected
484
- this.notifyServiceWorker('server-registered', { port, hostname, primary: port === this.primaryPort });
391
+ /** A guest's listener (the old null sentinel, now a request adapter) is answered over the loopback. */
392
+ protected override isGuest(entry: VirtualServer): boolean {
393
+ return entry.server === null || this.guestServers.has(entry.server);
485
394
  }
486
395
 
487
- /**
488
- * Unregister a server
489
- */
490
- unregisterServer(port: number): void {
491
- this.servers.delete(port);
492
- if (this.primaryPort === port) this.primaryPort = null;
493
- this.notifyServiceWorker('server-unregistered', { port });
396
+ protected override requestGuest(port: number, method: string, url: string, headers: Record<string, string>, body: Uint8Array | undefined): Promise<ResponseData> {
397
+ return __requestOverLoopback(port, method, url, headers, body as Buffer | undefined);
494
398
  }
495
399
 
496
- /** The server the service worker answers at the origin's root, the preview's; null for none. */
497
- private primaryPort: number | null = null;
498
-
499
- /**
500
- * Names the server the service worker serves at the origin's root, as an
501
- * app is served at its own origin's root: its documents read their routes
502
- * from `location.pathname`, which under /__virtual__/<port>/ is not the
503
- * path they expect. Told to the worker now and again whenever the worker
504
- * is (re)initialized, since a worker that restarted remembers nothing.
505
- */
506
- setPrimaryPort(port: number | null): void {
507
- const previous = this.primaryPort;
508
- this.primaryPort = port;
509
- if (previous !== null && previous !== port) {
510
- const entry = this.servers.get(previous);
511
- if (entry) this.notifyServiceWorker('server-registered', { port: previous, hostname: entry.hostname, primary: false });
512
- }
513
- if (port !== null) {
514
- const entry = this.servers.get(port);
515
- this.notifyServiceWorker('server-registered', { port, hostname: entry?.hostname ?? '0.0.0.0', primary: true });
516
- }
517
- }
518
-
519
- /** Every registration the worker should hold, sent again to a worker that was (re)initialized. */
520
- private announceServers(): void {
521
- if (this.ownDocuments) this.notifyServiceWorker('own-documents', { paths: this.ownDocuments });
522
- for (const [port, entry] of this.servers) {
523
- this.notifyServiceWorker('server-registered', { port, hostname: entry.hostname, primary: port === this.primaryPort });
524
- }
525
- }
526
- private ownDocuments: string[] | undefined;
527
-
528
- /**
529
- * Get server URL for a port
530
- */
531
- getServerUrl(port: number): string {
532
- return `${this.baseUrl}/__virtual__/${port}`;
533
- }
534
-
535
- /**
536
- * Get all registered server ports
537
- */
538
- getServerPorts(): number[] {
539
- return [...this.servers.keys()];
540
- }
541
-
542
- /**
543
- * Handle an incoming request from Service Worker
544
- */
545
- async handleRequest(
546
- port: number,
547
- method: string,
548
- url: string,
549
- headers: Record<string, string>,
550
- body?: ArrayBuffer
551
- ): Promise<ResponseData> {
552
- const virtualServer = this.servers.get(port);
553
-
554
- if (!virtualServer) {
555
- return {
556
- statusCode: 503,
557
- statusMessage: 'Service Unavailable',
558
- headers: { 'Content-Type': 'text/plain' },
559
- body: ownedBytes(Buffer.from(`No server listening on port ${port}`)),
560
- };
561
- }
562
-
563
- try {
564
- const bodyBuffer = body ? Buffer.from(new Uint8Array(body)) : undefined;
565
- // Every request a server sees carries a Host. HTTP/1.1 requires one and
566
- // Node's own parser answers 400 without it, so a server is written as
567
- // though it is always there: Next's middleware reads
568
- // `req.headers.get('host')` on its first line and Dub's died on null.
569
- // A browser's `fetch` may not send the header and a service worker may
570
- // not add it, so the request that arrives here often has none; the
571
- // server is listening on a port, and that is the authority it is
572
- // reachable at.
573
- const named = Object.keys(headers).some((name) => name.toLowerCase() === 'host');
574
- const authority = `${virtualServer.hostname && virtualServer.hostname !== '0.0.0.0' && virtualServer.hostname !== '::' ? virtualServer.hostname : '127.0.0.1'}:${port}`;
575
- const withHost = named ? headers : { ...headers, host: authority };
576
- // A server the host registered answers for itself; a guest's server is
577
- // a port this engine is listening on, and the request goes to it as
578
- // bytes on a connection, which is what a server reads.
579
- if (virtualServer.server) return await virtualServer.server.handleRequest(method, url, withHost, bodyBuffer);
580
- return await __requestOverLoopback(port, method, url, withHost, bodyBuffer);
581
- } catch (error) {
582
- const message = error instanceof Error ? error.message : 'Internal Server Error';
583
- return {
584
- statusCode: 500,
585
- statusMessage: 'Internal Server Error',
586
- headers: { 'Content-Type': 'text/plain' },
587
- body: ownedBytes(Buffer.from(message)),
588
- };
589
- }
590
- }
591
-
592
- /**
593
- * Initialize Service Worker communication
594
- * @param options - Configuration options for the service worker
595
- * @param options.swUrl - Custom URL path to the service worker file (default: '/__sw__.js')
596
- */
597
- async initServiceWorker(options?: InitServiceWorkerOptions): Promise<void> {
598
- if (!('serviceWorker' in navigator)) {
599
- throw new Error('Service Workers not supported');
600
- }
601
-
602
- const swUrl = options?.swUrl ?? '/__sw__.js';
603
-
604
- // Set up controllerchange listener BEFORE registration so we don't miss the event.
605
- // clients.claim() in the SW's activate handler fires controllerchange, and it can
606
- // happen before our activation wait completes.
607
- const controllerReady = navigator.serviceWorker.controller
608
- ? Promise.resolve()
609
- : new Promise<void>((resolve) => {
610
- navigator.serviceWorker.addEventListener('controllerchange', () => resolve(), { once: true });
611
- });
612
-
613
- // Register service worker
614
- const registration = await navigator.serviceWorker.register(swUrl, {
615
- scope: '/',
616
- });
617
-
618
- // Wait for service worker to be active
619
- const sw = registration.active || registration.waiting || registration.installing;
620
-
621
- if (!sw) {
622
- throw new Error('Service Worker registration failed');
623
- }
624
-
625
- await new Promise<void>((resolve) => {
626
- if (sw.state === 'activated') {
627
- resolve();
628
- } else {
629
- const handler = () => {
630
- if (sw.state === 'activated') {
631
- sw.removeEventListener('statechange', handler);
632
- resolve();
633
- }
634
- };
635
- sw.addEventListener('statechange', handler);
636
- }
637
- });
638
-
639
- // Set up message channel for communication
640
- this.messageChannel = new MessageChannel();
641
- this.messageChannel.port1.onmessage = this.handleServiceWorkerMessage.bind(this);
642
-
643
- this.ownDocuments = options?.ownDocuments;
644
- // Send port to service worker
645
- sw.postMessage({ type: 'init', port: this.messageChannel.port2, data: { ownDocuments: this.ownDocuments ?? [] } }, [
646
- this.messageChannel.port2,
647
- ]);
648
-
649
- // Wait for SW to actually control this page (clients.claim() in SW activate handler)
650
- // Without this, fetch requests bypass the SW and go directly to the server
651
- await controllerReady;
652
-
653
- // Re-establish communication when the SW loses its port (idle termination)
654
- // or when the SW is replaced (new deployment). The SW sends 'sw-needs-init'
655
- // to all clients when a request arrives but mainPort is null.
656
- const reinit = () => {
657
- if (navigator.serviceWorker.controller) {
658
- for (const stream of [...this.controlledStreams.values()]) stream.cancel();
659
- this.messageChannel = new MessageChannel();
660
- this.messageChannel.port1.onmessage = this.handleServiceWorkerMessage.bind(this);
661
- navigator.serviceWorker.controller.postMessage(
662
- { type: 'init', port: this.messageChannel.port2, data: { ownDocuments: this.ownDocuments ?? [] } },
663
- [this.messageChannel.port2]
664
- );
665
- this.announceServers();
666
- }
667
- };
668
- navigator.serviceWorker.addEventListener('controllerchange', reinit);
669
- navigator.serviceWorker.addEventListener('message', (event) => {
670
- if (event.data?.type === 'sw-needs-init') {
671
- reinit();
672
- }
673
- });
674
-
675
- // Keep the SW alive with periodic pings. Browsers terminate idle SWs
676
- // after ~30s, losing the MessageChannel port and all in-memory state.
677
- this.keepaliveInterval = setInterval(() => {
678
- this.messageChannel?.port1.postMessage({ type: 'keepalive' });
679
- }, 20_000);
680
-
681
- this.serviceWorkerReady = true;
682
- this.announceServers();
683
- this.emit('sw-ready');
684
- }
685
-
686
- /**
687
- * Handle messages from Service Worker
688
- */
689
- private async handleServiceWorkerMessage(event: MessageEvent): Promise<void> {
690
- const { type, id, data } = event.data;
691
-
692
- ServerBridge.DEBUG && console.log('[ServerBridge] SW message:', type, id, data?.url);
693
-
694
- if (type === 'stream-pull' || type === 'stream-cancel') {
695
- const stream = this.controlledStreams.get(id);
696
- if (type === 'stream-pull') stream?.pull(); else stream?.cancel();
697
- return;
698
- }
699
- if (type === 'request' && data?.flowControl === 1) {
700
- const { port, method, url, headers, body } = data;
701
- await this.controlledStream(id, port, method, url, headers, body);
702
- return;
703
- }
704
- if (type === 'request') {
705
- const { port, method, url, headers, body, streaming } = data;
706
-
707
- ServerBridge.DEBUG && console.log('[ServerBridge] Handling request:', port, method, url, 'streaming:', streaming);
708
- if (streaming) {
709
- ServerBridge.DEBUG && console.log('[ServerBridge] 🔴 Will use streaming handler');
710
- }
711
-
712
- try {
713
- if (streaming) {
714
- // Handle streaming request
715
- await this.streamToServiceWorker(id, port, method, url, headers, body);
716
- } else {
717
- // Handle regular request
718
- const response = await this.handleRequest(port, method, url, headers, body);
719
- ServerBridge.DEBUG && console.log('[ServerBridge] Response:', response.statusCode, 'body length:', response.body?.length);
720
-
721
- // Convert body to base64 string to avoid structured cloning issues with Uint8Array
722
- let bodyBase64 = '';
723
- if (response.body && response.body.length > 0) {
724
- const bytes = response.body instanceof Uint8Array ? response.body : new Uint8Array(0);
725
- bodyBase64 = uint8ToBase64(bytes);
726
- }
727
-
728
- ServerBridge.DEBUG && console.log('[ServerBridge] Sending response to SW, body base64 length:', bodyBase64.length);
729
-
730
- this.messageChannel?.port1.postMessage({
731
- type: 'response',
732
- id,
733
- data: {
734
- statusCode: response.statusCode,
735
- statusMessage: response.statusMessage,
736
- headers: response.headers,
737
- bodyBase64: bodyBase64,
738
- },
739
- });
740
- }
741
- } catch (error) {
742
- this.messageChannel?.port1.postMessage({
743
- type: 'response',
744
- id,
745
- error: error instanceof Error ? error.message : 'Unknown error',
746
- });
747
- }
748
- }
749
- }
750
-
751
- /**
752
- * Handle a streaming request - sends chunks as they arrive
753
- */
754
- /**
755
- * A page-side request streamed to the caller as it arrives, the door a host
756
- * reads a guest's server through when it wants chunks rather than a body:
757
- * a guest's own server is reached over the loopback as any client reaches
758
- * it; a server the host registered answers through its own streaming
759
- * method where it has one, else its buffered answer is delivered whole.
760
- */
761
- async handleStreamingRequest(
762
- port: number,
763
- method: string,
764
- url: string,
765
- headers: Record<string, string>,
766
- body: ArrayBuffer | undefined,
767
- callbacks: {
768
- start(statusCode: number, statusMessage: string, headers: Record<string, string>): void;
769
- chunk(chunk: Uint8Array): void;
770
- end(): void;
771
- },
400
+ protected override streamGuest(
401
+ port: number, method: string, url: string, headers: Record<string, string>, body: Uint8Array | undefined,
402
+ onStart: (statusCode: number, statusMessage: string, headers: Record<string, string>) => void,
403
+ onChunk: (chunk: Uint8Array) => void,
404
+ onEnd: () => void,
772
405
  flow?: LoopbackStreamFlow,
773
- ): Promise<boolean> {
774
- const virtualServer = this.servers.get(port);
775
- if (!virtualServer) return false;
776
- const bodyBuffer = body ? Buffer.from(new Uint8Array(body)) : undefined;
777
- // A guest's listener (the old null sentinel, now a request adapter) is
778
- // streamed off its connection; its adapter would answer whole.
779
- if (virtualServer.server === null || this.guestServers.has(virtualServer.server)) {
780
- const named = Object.keys(headers).some((name) => name.toLowerCase() === 'host');
781
- const authority = `${virtualServer.hostname && virtualServer.hostname !== '0.0.0.0' && virtualServer.hostname !== '::' ? virtualServer.hostname : '127.0.0.1'}:${port}`;
782
- await __streamOverLoopback(
783
- port, method, url, named ? headers : { ...headers, host: authority }, bodyBuffer,
784
- (statusCode, statusMessage, respHeaders) => callbacks.start(statusCode, statusMessage, respHeaders),
785
- (chunk) => callbacks.chunk(ownedBytes(chunk)),
786
- () => callbacks.end(),
787
- flow,
788
- );
789
- return true;
790
- }
791
- const server = virtualServer.server as IVirtualServer & {
792
- handleStreamingRequest?: (
793
- method: string, url: string, headers: Record<string, string>, body: Buffer | undefined,
794
- onStart: (statusCode: number, statusMessage: string, headers: Record<string, string>) => void,
795
- onChunk: (chunk: string | Uint8Array) => void,
796
- onEnd: () => void,
797
- flow?: LoopbackStreamFlow,
798
- ) => Promise<void>;
799
- };
800
- if (typeof server.handleStreamingRequest === 'function') {
801
- // a registered server that streams is paced the same way: it is handed
802
- // the reader's going away and the pause and resume of its own producer
803
- await server.handleStreamingRequest(
804
- method, url, headers, bodyBuffer,
805
- (statusCode, statusMessage, respHeaders) => callbacks.start(statusCode, statusMessage, respHeaders),
806
- (chunk) => callbacks.chunk(typeof chunk === 'string' ? _encoder.encode(chunk) : ownedBytes(chunk)),
807
- () => callbacks.end(),
808
- flow,
809
- );
810
- return true;
811
- }
812
- const response = await this.handleRequest(port, method, url, headers, body);
813
- callbacks.start(response.statusCode, response.statusMessage ?? '', response.headers as Record<string, string>);
814
- if (response.body) {
815
- const body = typeof response.body === 'string' ? _encoder.encode(response.body) : response.body;
816
- callbacks.chunk(ownedBytes(body));
817
- }
818
- callbacks.end();
819
- return true;
820
- }
821
-
822
- private async streamToServiceWorker(
823
- id: number,
824
- port: number,
825
- method: string,
826
- url: string,
827
- headers: Record<string, string>,
828
- body?: ArrayBuffer
829
406
  ): Promise<void> {
830
- const virtualServer = this.servers.get(port);
831
-
832
- if (!virtualServer) {
833
- this.messageChannel?.port1.postMessage({
834
- type: 'stream-start',
835
- id,
836
- data: { statusCode: 503, statusMessage: 'Service Unavailable', headers: {} },
837
- });
838
- this.messageChannel?.port1.postMessage({ type: 'stream-end', id });
839
- return;
840
- }
841
-
842
- // A guest's server is a port: its answer is streamed off the connection
843
- // as it arrives, which is what the page reads chunk by chunk.
844
- if (virtualServer.server === null || this.guestServers.has(virtualServer.server)) {
845
- const bodyBuffer = body ? Buffer.from(new Uint8Array(body)) : undefined;
846
- const named = Object.keys(headers).some((name) => name.toLowerCase() === 'host');
847
- const authority = `${virtualServer.hostname && virtualServer.hostname !== '0.0.0.0' && virtualServer.hostname !== '::' ? virtualServer.hostname : '127.0.0.1'}:${port}`;
848
- await __streamOverLoopback(
849
- port, method, url, named ? headers : { ...headers, host: authority }, bodyBuffer,
850
- (statusCode, statusMessage, respHeaders) => {
851
- this.messageChannel?.port1.postMessage({ type: 'stream-start', id, data: { statusCode, statusMessage, headers: respHeaders } });
852
- },
853
- (chunk) => {
854
- // A pooled Buffer's `.slice()` is a window on the same 8 KB; the
855
- // transfer of `copy.buffer` would move the pool. These are the
856
- // bytes the view names, in a buffer of their own.
857
- const copy = ownedBytes(chunk);
858
- this.messageChannel?.port1.postMessage({ type: 'stream-chunk', id, data: copy.buffer }, [copy.buffer]);
859
- },
860
- () => { this.messageChannel?.port1.postMessage({ type: 'stream-end', id }); },
861
- );
862
- return;
863
- }
864
-
865
- // Check if the server supports streaming (has handleStreamingRequest method)
866
- const server = virtualServer.server as any;
867
- if (typeof server.handleStreamingRequest === 'function') {
868
- ServerBridge.DEBUG && console.log('[ServerBridge] 🟢 Server has streaming support, calling handleStreamingRequest');
869
- // Use streaming handler
870
- const bodyBuffer = body ? Buffer.from(new Uint8Array(body)) : undefined;
871
-
872
- await server.handleStreamingRequest(
873
- method,
874
- url,
875
- headers,
876
- bodyBuffer,
877
- // onStart - called with headers
878
- (statusCode: number, statusMessage: string, respHeaders: Record<string, string>) => {
879
- ServerBridge.DEBUG && console.log('[ServerBridge] 🟢 onStart called, sending stream-start');
880
- this.messageChannel?.port1.postMessage({
881
- type: 'stream-start',
882
- id,
883
- data: { statusCode, statusMessage, headers: respHeaders },
884
- });
885
- },
886
- // onChunk - called for each chunk
887
- (chunk: string | Uint8Array) => {
888
- const bytes = typeof chunk === 'string' ? _encoder.encode(chunk) : chunk;
889
- const chunkBase64 = uint8ToBase64(bytes);
890
- ServerBridge.DEBUG && console.log('[ServerBridge] 🟡 onChunk called, sending stream-chunk, size:', chunkBase64.length);
891
- this.messageChannel?.port1.postMessage({
892
- type: 'stream-chunk',
893
- id,
894
- data: { chunkBase64 },
895
- });
896
- },
897
- // onEnd - called when response is complete
898
- () => {
899
- ServerBridge.DEBUG && console.log('[ServerBridge] 🟢 onEnd called, sending stream-end');
900
- this.messageChannel?.port1.postMessage({ type: 'stream-end', id });
901
- }
902
- );
903
- } else {
904
- // Fall back to regular request handling
905
- const bodyBuffer = body ? Buffer.from(new Uint8Array(body)) : undefined;
906
- const response = await virtualServer.server.handleRequest(method, url, headers, bodyBuffer);
907
-
908
- // Send as a single stream
909
- this.messageChannel?.port1.postMessage({
910
- type: 'stream-start',
911
- id,
912
- data: {
913
- statusCode: response.statusCode,
914
- statusMessage: response.statusMessage,
915
- headers: response.headers,
916
- },
917
- });
918
-
919
- if (response.body && response.body.length > 0) {
920
- const bytes = response.body instanceof Uint8Array ? response.body : new Uint8Array(0);
921
- this.messageChannel?.port1.postMessage({
922
- type: 'stream-chunk',
923
- id,
924
- data: { chunkBase64: uint8ToBase64(bytes) },
925
- });
926
- }
927
-
928
- this.messageChannel?.port1.postMessage({ type: 'stream-end', id });
929
- }
930
- }
931
-
932
- /**
933
- * Send message to Service Worker
934
- */
935
- private notifyServiceWorker(type: string, data: unknown): void {
936
- if (this.serviceWorkerReady && this.messageChannel) {
937
- // Every port this bridge answers speaks the worker's flow-controlled
938
- // stream: a response is paced by the reader's pulls, one chunk a pull,
939
- // and a reader that goes away ends the request (virtual HTTP flow
940
- // control, the worker's side of which is in public/__sw__.js).
941
- const flow = type === 'server-registered'
942
- ? { flowControl: 1, maxRequestBytes: FLOW_MAX_REQUEST_BYTES, maxChunkBytes: FLOW_MAX_CHUNK_BYTES } : {};
943
- this.messageChannel.port1.postMessage({ type, data: { ...(data as object), ...flow } });
944
- }
407
+ return __streamOverLoopback(port, method, url, headers, body as Buffer | undefined, onStart, onChunk, onEnd, flow);
945
408
  }
946
409
 
947
- /** Flow-controlled requests in flight: a pull is one chunk's credit, a cancel ends the upstream. */
948
- private readonly controlledStreams = new Map<number, { pull(): void; cancel(): void }>();
949
-
950
- /**
951
- * A request the worker sent under flow control: the response starts with
952
- * its head, then goes one chunk (at most FLOW_MAX_CHUNK_BYTES) for each
953
- * `stream-pull`, and ends with `stream-end` once the server finished and
954
- * every chunk went out. The upstream connection is paused while chunks wait
955
- * for credit, so a slow reader holds the server back; `stream-cancel` ends it.
956
- */
957
- private async controlledStream(id: number, port: number, method: string, url: string, headers: Record<string, string>, body?: ArrayBuffer): Promise<void> {
958
- // the stream belongs to the channel it arrived on; a new channel's worker failed it already
959
- const channel = this.messageChannel;
960
- const post = (type: string, data?: unknown): void => { channel?.port1.postMessage({ type, id, ...(data === undefined ? {} : { data }) }); };
961
- const queue: Uint8Array[] = [];
962
- const abort = new AbortController();
963
- let credits = 0;
964
- let upstreamEnded = false;
965
- let finished = false;
966
- let paused = false;
967
- let pause = (): void => undefined;
968
- let resume = (): void => undefined;
969
- const finish = (): void => { finished = true; this.controlledStreams.delete(id); };
970
- const flush = (): void => {
971
- if (finished) return;
972
- while (credits > 0 && queue.length > 0) {
973
- credits -= 1;
974
- post('stream-chunk', { chunkBase64: uint8ToBase64(queue.shift()!) });
975
- }
976
- if (upstreamEnded && queue.length === 0) { post('stream-end'); finish(); return; }
977
- // a few chunks may wait for credit; more than that, and the server waits
978
- if (queue.length >= FLOW_QUEUED_CHUNKS && !paused) { paused = true; pause(); }
979
- else if (queue.length < FLOW_QUEUED_CHUNKS && paused) { paused = false; resume(); }
980
- };
981
- this.controlledStreams.set(id, {
982
- pull: () => { credits += 1; flush(); },
983
- cancel: () => { if (finished) return; finish(); queue.length = 0; abort.abort(); },
984
- });
985
- try {
986
- const answered = await this.handleStreamingRequest(port, method, url, headers, body, {
987
- start: (statusCode, statusMessage, respHeaders) => post('stream-start', { statusCode, statusMessage, headers: respHeaders }),
988
- chunk: (chunk) => {
989
- if (finished) return;
990
- for (let at = 0; at < chunk.byteLength; at += FLOW_MAX_CHUNK_BYTES) queue.push(chunk.slice(at, at + FLOW_MAX_CHUNK_BYTES));
991
- flush();
992
- },
993
- end: () => { upstreamEnded = true; flush(); },
994
- }, { signal: abort.signal, control: (p, r) => { pause = p; resume = r; } });
995
- if (!answered) {
996
- post('stream-start', { statusCode: 503, statusMessage: 'Service Unavailable', headers: {} });
997
- post('stream-end');
998
- finish();
999
- }
1000
- } catch (error) {
1001
- if (!finished) { post('stream-error', { message: error instanceof Error ? error.message : String(error) }); finish(); }
1002
- }
1003
- }
1004
-
1005
- /**
1006
- * Create a mock request handler for testing without Service Worker
1007
- */
1008
- createFetchHandler(): (request: Request) => Promise<Response> {
1009
- return async (request: Request): Promise<Response> => {
1010
- const url = new URL(request.url);
1011
-
1012
- // Check if this is a virtual server request
1013
- const match = url.pathname.match(/^\/__virtual__\/(\d+)(\/.*)?$/);
1014
- if (!match) {
1015
- throw new Error('Not a virtual server request');
1016
- }
1017
-
1018
- const port = parseInt(match[1], 10);
1019
- const path = match[2] || '/';
1020
-
1021
- // Build headers object
1022
- const headers: Record<string, string> = {};
1023
- request.headers.forEach((value, key) => {
1024
- headers[key] = value;
1025
- });
1026
-
1027
- // Get body if present
1028
- let body: ArrayBuffer | undefined;
1029
- if (request.method !== 'GET' && request.method !== 'HEAD') {
1030
- body = await request.arrayBuffer();
1031
- }
1032
-
1033
- // Handle request
1034
- const response = await this.handleRequest(
1035
- port,
1036
- request.method,
1037
- path + url.search,
1038
- headers,
1039
- body
1040
- );
1041
-
1042
- // Convert to fetch Response
1043
- // A header sent more than once, `set-cookie` above all, is appended as
1044
- // many times; a fetch Response takes each value on its own.
1045
- const answerHeaders = new Headers();
1046
- for (const [name, value] of Object.entries(response.headers)) {
1047
- for (const each of Array.isArray(value) ? value : [value]) answerHeaders.append(name, each);
1048
- }
1049
- return new Response(response.body as unknown as BodyInit, {
1050
- status: response.statusCode,
1051
- statusText: response.statusMessage ?? '',
1052
- headers: answerHeaders,
1053
- });
1054
- };
410
+ /** A server of this engine reads a request's body as Node's Buffer. */
411
+ protected override bodyOf(bytes: Uint8Array): Uint8Array {
412
+ return Buffer.from(bytes);
1055
413
  }
1056
414
  }
1057
415