@volter/tabnode 0.5.25 → 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.
- package/dist/assets/{runtime-worker-D6N3DujB.js → runtime-worker-DPvtXRd8.js} +248 -21
- package/dist/index.d.ts +2 -1
- package/dist/index.mjs +288 -599
- package/dist/port-bridge-CixAESwA.js +655 -0
- package/dist/port-bridge.d.ts +175 -0
- package/dist/port-bridge.mjs +6 -0
- package/dist/runtime.d.ts +13 -0
- package/dist/server-bridge.d.ts +9 -130
- package/package.json +5 -1
- package/src/index.ts +2 -1
- package/src/port-bridge.ts +791 -0
- package/src/runtime.ts +73 -38
- package/src/server-bridge.ts +22 -664
|
@@ -0,0 +1,791 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page's side of the virtual-port service worker (public/__sw__.js): the
|
|
3
|
+
* servers a host registered, by port, and the protocol the worker speaks to
|
|
4
|
+
* reach them, flow control and streaming included. It carries none of Node's
|
|
5
|
+
* library, so a page whose engine runs in a worker loads no engine to serve
|
|
6
|
+
* that worker's ports (browser-substrate ADR-0042). An engine's own bridge,
|
|
7
|
+
* `ServerBridge`, extends it with the servers its guests listen on.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ResponseData, LoopbackStreamFlow } from './node-lib/http-bridge';
|
|
11
|
+
import { uint8ToBase64 } from './utils/binary-encoding';
|
|
12
|
+
|
|
13
|
+
const _encoder = new TextEncoder();
|
|
14
|
+
// The worker's flow-controlled stream: a request body up to this size, and a
|
|
15
|
+
// response in chunks of exactly the worker's own maximum, one per pull.
|
|
16
|
+
const FLOW_MAX_REQUEST_BYTES = 64 * 1024 * 1024;
|
|
17
|
+
const FLOW_MAX_CHUNK_BYTES = 65536;
|
|
18
|
+
// Chunks that may wait for the reader's credit before the server is paused.
|
|
19
|
+
const FLOW_QUEUED_CHUNKS = 4;
|
|
20
|
+
|
|
21
|
+
/** The bytes this view names, in a buffer of their own. A pooled Buffer is a window on 8 KB. */
|
|
22
|
+
export function ownedBytes(view: Uint8Array): Uint8Array {
|
|
23
|
+
const copy = new Uint8Array(view.byteLength);
|
|
24
|
+
copy.set(new Uint8Array(view.buffer, view.byteOffset, view.byteLength));
|
|
25
|
+
return copy;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type { ResponseData, LoopbackStreamFlow };
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Interface for virtual servers that can be registered with the bridge
|
|
32
|
+
*/
|
|
33
|
+
export interface IVirtualServer {
|
|
34
|
+
listening: boolean;
|
|
35
|
+
/** What `net.Server.address()` answers: an address and a port, or the PATH of a unix-domain socket. */
|
|
36
|
+
address(): { port: number; address: string; family: string } | string | null;
|
|
37
|
+
handleRequest(
|
|
38
|
+
method: string,
|
|
39
|
+
url: string,
|
|
40
|
+
headers: Record<string, string>,
|
|
41
|
+
body?: Uint8Array | string
|
|
42
|
+
): Promise<ResponseData>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface VirtualServer {
|
|
46
|
+
/**
|
|
47
|
+
* A server the host registered and answers itself, or null for a guest's
|
|
48
|
+
* own: a guest's server is a port this engine is listening on, and it is
|
|
49
|
+
* reached by connecting to it like any other client.
|
|
50
|
+
*/
|
|
51
|
+
server: IVirtualServer | null;
|
|
52
|
+
port: number;
|
|
53
|
+
hostname: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface BridgeOptions {
|
|
57
|
+
baseUrl?: string;
|
|
58
|
+
onServerReady?: (port: number, url: string) => void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface InitServiceWorkerOptions {
|
|
62
|
+
/**
|
|
63
|
+
* The URL path to the service worker file
|
|
64
|
+
* @default '/__sw__.js'
|
|
65
|
+
*/
|
|
66
|
+
swUrl?: string;
|
|
67
|
+
/**
|
|
68
|
+
* The page's own documents that are frames of it (a sandbox, a worker
|
|
69
|
+
* host), by path, so the worker does not take one for the preview's.
|
|
70
|
+
*/
|
|
71
|
+
ownDocuments?: string[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type Listener = (...args: any[]) => void;
|
|
75
|
+
|
|
76
|
+
/** The events a bridge announces, `server-ready` and `sw-ready`, without Node's EventEmitter. */
|
|
77
|
+
class BridgeEvents {
|
|
78
|
+
private readonly listenersByEvent = new Map<string | symbol, Listener[]>();
|
|
79
|
+
|
|
80
|
+
on(event: string | symbol, listener: Listener): this {
|
|
81
|
+
const list = this.listenersByEvent.get(event) ?? [];
|
|
82
|
+
list.push(listener);
|
|
83
|
+
this.listenersByEvent.set(event, list);
|
|
84
|
+
return this;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
addListener(event: string | symbol, listener: Listener): this { return this.on(event, listener); }
|
|
88
|
+
|
|
89
|
+
once(event: string | symbol, listener: Listener): this {
|
|
90
|
+
const wrapped: Listener & { listener?: Listener } = (...args) => { this.off(event, wrapped); listener(...args); };
|
|
91
|
+
wrapped.listener = listener;
|
|
92
|
+
return this.on(event, wrapped);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
off(event: string | symbol, listener: Listener): this {
|
|
96
|
+
const list = this.listenersByEvent.get(event);
|
|
97
|
+
if (!list) return this;
|
|
98
|
+
const at = list.findIndex((candidate) => candidate === listener || (candidate as Listener & { listener?: Listener }).listener === listener);
|
|
99
|
+
if (at >= 0) list.splice(at, 1);
|
|
100
|
+
if (list.length === 0) this.listenersByEvent.delete(event);
|
|
101
|
+
return this;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
removeListener(event: string | symbol, listener: Listener): this { return this.off(event, listener); }
|
|
105
|
+
|
|
106
|
+
removeAllListeners(event?: string | symbol): this {
|
|
107
|
+
if (event === undefined) this.listenersByEvent.clear();
|
|
108
|
+
else this.listenersByEvent.delete(event);
|
|
109
|
+
return this;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
listenerCount(event: string | symbol): number {
|
|
113
|
+
return this.listenersByEvent.get(event)?.length ?? 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
emit(event: string | symbol, ...args: unknown[]): boolean {
|
|
117
|
+
const list = this.listenersByEvent.get(event);
|
|
118
|
+
if (!list || list.length === 0) return false;
|
|
119
|
+
for (const listener of [...list]) listener(...args);
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Server Bridge manages virtual HTTP servers and routes requests
|
|
126
|
+
*/
|
|
127
|
+
export class PortBridge extends BridgeEvents {
|
|
128
|
+
static DEBUG = false;
|
|
129
|
+
servers: Map<number, VirtualServer> = new Map();
|
|
130
|
+
private baseUrl: string;
|
|
131
|
+
private options: BridgeOptions;
|
|
132
|
+
private messageChannel: MessageChannel | null = null;
|
|
133
|
+
private serviceWorkerReady: boolean = false;
|
|
134
|
+
private keepaliveInterval: ReturnType<typeof setInterval> | null = null;
|
|
135
|
+
|
|
136
|
+
constructor(options: BridgeOptions = {}) {
|
|
137
|
+
super();
|
|
138
|
+
this.options = options;
|
|
139
|
+
|
|
140
|
+
// Handle browser vs Node.js environment
|
|
141
|
+
if (typeof location !== 'undefined') {
|
|
142
|
+
this.baseUrl = options.baseUrl || `${location.protocol}//${location.host}`;
|
|
143
|
+
} else {
|
|
144
|
+
this.baseUrl = options.baseUrl || 'http://localhost';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Give back everything this bridge opened: the service worker keepalive,
|
|
151
|
+
* and in an engine's bridge its upgrade channel. A host that is done with a
|
|
152
|
+
* container calls it and has its process back; calling it twice is nothing.
|
|
153
|
+
*/
|
|
154
|
+
close(): void {
|
|
155
|
+
if (this.keepaliveInterval !== null) {
|
|
156
|
+
clearInterval(this.keepaliveInterval);
|
|
157
|
+
this.keepaliveInterval = null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Register a server on a port
|
|
163
|
+
*/
|
|
164
|
+
registerServer(server: IVirtualServer | null, port: number, hostname: string = '0.0.0.0'): void {
|
|
165
|
+
this.servers.set(port, { server, port, hostname });
|
|
166
|
+
|
|
167
|
+
// Emit server-ready event
|
|
168
|
+
const url = this.getServerUrl(port);
|
|
169
|
+
this.emit('server-ready', port, url);
|
|
170
|
+
|
|
171
|
+
if (this.options.onServerReady) {
|
|
172
|
+
this.options.onServerReady(port, url);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Notify service worker if connected
|
|
176
|
+
this.notifyServiceWorker('server-registered', { port, hostname, primary: port === this.primaryPort });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Unregister a server
|
|
181
|
+
*/
|
|
182
|
+
unregisterServer(port: number): void {
|
|
183
|
+
this.servers.delete(port);
|
|
184
|
+
if (this.primaryPort === port) this.primaryPort = null;
|
|
185
|
+
this.notifyServiceWorker('server-unregistered', { port });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The server the service worker answers at the origin's root, the preview's; null for none. */
|
|
189
|
+
private primaryPort: number | null = null;
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Names the server the service worker serves at the origin's root, as an
|
|
193
|
+
* app is served at its own origin's root: its documents read their routes
|
|
194
|
+
* from `location.pathname`, which under /__virtual__/<port>/ is not the
|
|
195
|
+
* path they expect. Told to the worker now and again whenever the worker
|
|
196
|
+
* is (re)initialized, since a worker that restarted remembers nothing.
|
|
197
|
+
*/
|
|
198
|
+
setPrimaryPort(port: number | null): void {
|
|
199
|
+
const previous = this.primaryPort;
|
|
200
|
+
this.primaryPort = port;
|
|
201
|
+
if (previous !== null && previous !== port) {
|
|
202
|
+
const entry = this.servers.get(previous);
|
|
203
|
+
if (entry) this.notifyServiceWorker('server-registered', { port: previous, hostname: entry.hostname, primary: false });
|
|
204
|
+
}
|
|
205
|
+
if (port !== null) {
|
|
206
|
+
const entry = this.servers.get(port);
|
|
207
|
+
this.notifyServiceWorker('server-registered', { port, hostname: entry?.hostname ?? '0.0.0.0', primary: true });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Every registration the worker should hold, sent again to a worker that was (re)initialized. */
|
|
212
|
+
private announceServers(): void {
|
|
213
|
+
if (this.ownDocuments) this.notifyServiceWorker('own-documents', { paths: this.ownDocuments });
|
|
214
|
+
for (const [port, entry] of this.servers) {
|
|
215
|
+
this.notifyServiceWorker('server-registered', { port, hostname: entry.hostname, primary: port === this.primaryPort });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
private ownDocuments: string[] | undefined;
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Get server URL for a port
|
|
222
|
+
*/
|
|
223
|
+
getServerUrl(port: number): string {
|
|
224
|
+
return `${this.baseUrl}/__virtual__/${port}`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Get all registered server ports
|
|
229
|
+
*/
|
|
230
|
+
getServerPorts(): number[] {
|
|
231
|
+
return [...this.servers.keys()];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Handle an incoming request from Service Worker
|
|
236
|
+
*/
|
|
237
|
+
async handleRequest(
|
|
238
|
+
port: number,
|
|
239
|
+
method: string,
|
|
240
|
+
url: string,
|
|
241
|
+
headers: Record<string, string>,
|
|
242
|
+
body?: ArrayBuffer
|
|
243
|
+
): Promise<ResponseData> {
|
|
244
|
+
const virtualServer = this.servers.get(port);
|
|
245
|
+
|
|
246
|
+
if (!virtualServer) {
|
|
247
|
+
return {
|
|
248
|
+
statusCode: 503,
|
|
249
|
+
statusMessage: 'Service Unavailable',
|
|
250
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
251
|
+
body: _encoder.encode(`No server listening on port ${port}`),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
const bodyBuffer = body ? this.bodyOf(new Uint8Array(body)) : undefined;
|
|
257
|
+
// Every request a server sees carries a Host. HTTP/1.1 requires one and
|
|
258
|
+
// Node's own parser answers 400 without it, so a server is written as
|
|
259
|
+
// though it is always there: Next's middleware reads
|
|
260
|
+
// `req.headers.get('host')` on its first line and Dub's died on null.
|
|
261
|
+
// A browser's `fetch` may not send the header and a service worker may
|
|
262
|
+
// not add it, so the request that arrives here often has none; the
|
|
263
|
+
// server is listening on a port, and that is the authority it is
|
|
264
|
+
// reachable at.
|
|
265
|
+
const named = Object.keys(headers).some((name) => name.toLowerCase() === 'host');
|
|
266
|
+
const authority = `${virtualServer.hostname && virtualServer.hostname !== '0.0.0.0' && virtualServer.hostname !== '::' ? virtualServer.hostname : '127.0.0.1'}:${port}`;
|
|
267
|
+
const withHost = named ? headers : { ...headers, host: authority };
|
|
268
|
+
// A server the host registered answers for itself; a guest's server is
|
|
269
|
+
// a port this engine is listening on, and the request goes to it as
|
|
270
|
+
// bytes on a connection, which is what a server reads.
|
|
271
|
+
if (virtualServer.server) return await virtualServer.server.handleRequest(method, url, withHost, bodyBuffer);
|
|
272
|
+
return await this.requestGuest(port, method, url, withHost, bodyBuffer);
|
|
273
|
+
} catch (error) {
|
|
274
|
+
const message = error instanceof Error ? error.message : 'Internal Server Error';
|
|
275
|
+
return {
|
|
276
|
+
statusCode: 500,
|
|
277
|
+
statusMessage: 'Internal Server Error',
|
|
278
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
279
|
+
body: _encoder.encode(message),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Initialize Service Worker communication
|
|
286
|
+
* @param options - Configuration options for the service worker
|
|
287
|
+
* @param options.swUrl - Custom URL path to the service worker file (default: '/__sw__.js')
|
|
288
|
+
*/
|
|
289
|
+
async initServiceWorker(options?: InitServiceWorkerOptions): Promise<void> {
|
|
290
|
+
if (!('serviceWorker' in navigator)) {
|
|
291
|
+
throw new Error('Service Workers not supported');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const swUrl = options?.swUrl ?? '/__sw__.js';
|
|
295
|
+
|
|
296
|
+
// Set up controllerchange listener BEFORE registration so we don't miss the event.
|
|
297
|
+
// clients.claim() in the SW's activate handler fires controllerchange, and it can
|
|
298
|
+
// happen before our activation wait completes.
|
|
299
|
+
const controllerReady = navigator.serviceWorker.controller
|
|
300
|
+
? Promise.resolve()
|
|
301
|
+
: new Promise<void>((resolve) => {
|
|
302
|
+
navigator.serviceWorker.addEventListener('controllerchange', () => resolve(), { once: true });
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// Register service worker
|
|
306
|
+
const registration = await navigator.serviceWorker.register(swUrl, {
|
|
307
|
+
scope: '/',
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// Wait for service worker to be active
|
|
311
|
+
const sw = registration.active || registration.waiting || registration.installing;
|
|
312
|
+
|
|
313
|
+
if (!sw) {
|
|
314
|
+
throw new Error('Service Worker registration failed');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
await new Promise<void>((resolve) => {
|
|
318
|
+
if (sw.state === 'activated') {
|
|
319
|
+
resolve();
|
|
320
|
+
} else {
|
|
321
|
+
const handler = () => {
|
|
322
|
+
if (sw.state === 'activated') {
|
|
323
|
+
sw.removeEventListener('statechange', handler);
|
|
324
|
+
resolve();
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
sw.addEventListener('statechange', handler);
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// Set up message channel for communication
|
|
332
|
+
this.messageChannel = new MessageChannel();
|
|
333
|
+
this.messageChannel.port1.onmessage = this.handleServiceWorkerMessage.bind(this);
|
|
334
|
+
|
|
335
|
+
this.ownDocuments = options?.ownDocuments;
|
|
336
|
+
// Send port to service worker
|
|
337
|
+
sw.postMessage({ type: 'init', port: this.messageChannel.port2, data: { ownDocuments: this.ownDocuments ?? [] } }, [
|
|
338
|
+
this.messageChannel.port2,
|
|
339
|
+
]);
|
|
340
|
+
|
|
341
|
+
// Wait for SW to actually control this page (clients.claim() in SW activate handler)
|
|
342
|
+
// Without this, fetch requests bypass the SW and go directly to the server
|
|
343
|
+
await controllerReady;
|
|
344
|
+
|
|
345
|
+
// Re-establish communication when the SW loses its port (idle termination)
|
|
346
|
+
// or when the SW is replaced (new deployment). The SW sends 'sw-needs-init'
|
|
347
|
+
// to all clients when a request arrives but mainPort is null.
|
|
348
|
+
const reinit = () => {
|
|
349
|
+
if (navigator.serviceWorker.controller) {
|
|
350
|
+
for (const stream of [...this.controlledStreams.values()]) stream.cancel();
|
|
351
|
+
this.messageChannel = new MessageChannel();
|
|
352
|
+
this.messageChannel.port1.onmessage = this.handleServiceWorkerMessage.bind(this);
|
|
353
|
+
navigator.serviceWorker.controller.postMessage(
|
|
354
|
+
{ type: 'init', port: this.messageChannel.port2, data: { ownDocuments: this.ownDocuments ?? [] } },
|
|
355
|
+
[this.messageChannel.port2]
|
|
356
|
+
);
|
|
357
|
+
this.announceServers();
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
navigator.serviceWorker.addEventListener('controllerchange', reinit);
|
|
361
|
+
navigator.serviceWorker.addEventListener('message', (event) => {
|
|
362
|
+
if (event.data?.type === 'sw-needs-init') {
|
|
363
|
+
reinit();
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// Keep the SW alive with periodic pings. Browsers terminate idle SWs
|
|
368
|
+
// after ~30s, losing the MessageChannel port and all in-memory state.
|
|
369
|
+
this.keepaliveInterval = setInterval(() => {
|
|
370
|
+
this.messageChannel?.port1.postMessage({ type: 'keepalive' });
|
|
371
|
+
}, 20_000);
|
|
372
|
+
|
|
373
|
+
this.serviceWorkerReady = true;
|
|
374
|
+
this.announceServers();
|
|
375
|
+
this.emit('sw-ready');
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Handle messages from Service Worker
|
|
380
|
+
*/
|
|
381
|
+
private async handleServiceWorkerMessage(event: MessageEvent): Promise<void> {
|
|
382
|
+
const { type, id, data } = event.data;
|
|
383
|
+
|
|
384
|
+
PortBridge.DEBUG && console.log('[ServerBridge] SW message:', type, id, data?.url);
|
|
385
|
+
|
|
386
|
+
if (type === 'stream-pull' || type === 'stream-cancel') {
|
|
387
|
+
const stream = this.controlledStreams.get(id);
|
|
388
|
+
if (type === 'stream-pull') stream?.pull(); else stream?.cancel();
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (type === 'request' && data?.flowControl === 1) {
|
|
392
|
+
const { port, method, url, headers, body } = data;
|
|
393
|
+
await this.controlledStream(id, port, method, url, headers, body);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (type === 'request') {
|
|
397
|
+
const { port, method, url, headers, body, streaming } = data;
|
|
398
|
+
|
|
399
|
+
PortBridge.DEBUG && console.log('[ServerBridge] Handling request:', port, method, url, 'streaming:', streaming);
|
|
400
|
+
if (streaming) {
|
|
401
|
+
PortBridge.DEBUG && console.log('[ServerBridge] 🔴 Will use streaming handler');
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
try {
|
|
405
|
+
if (streaming) {
|
|
406
|
+
// Handle streaming request
|
|
407
|
+
await this.streamToServiceWorker(id, port, method, url, headers, body);
|
|
408
|
+
} else {
|
|
409
|
+
// Handle regular request
|
|
410
|
+
const response = await this.handleRequest(port, method, url, headers, body);
|
|
411
|
+
PortBridge.DEBUG && console.log('[ServerBridge] Response:', response.statusCode, 'body length:', response.body?.length);
|
|
412
|
+
|
|
413
|
+
// Convert body to base64 string to avoid structured cloning issues with Uint8Array
|
|
414
|
+
let bodyBase64 = '';
|
|
415
|
+
if (response.body && response.body.length > 0) {
|
|
416
|
+
const bytes = response.body instanceof Uint8Array ? response.body : new Uint8Array(0);
|
|
417
|
+
bodyBase64 = uint8ToBase64(bytes);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
PortBridge.DEBUG && console.log('[ServerBridge] Sending response to SW, body base64 length:', bodyBase64.length);
|
|
421
|
+
|
|
422
|
+
this.messageChannel?.port1.postMessage({
|
|
423
|
+
type: 'response',
|
|
424
|
+
id,
|
|
425
|
+
data: {
|
|
426
|
+
statusCode: response.statusCode,
|
|
427
|
+
statusMessage: response.statusMessage,
|
|
428
|
+
headers: response.headers,
|
|
429
|
+
bodyBase64: bodyBase64,
|
|
430
|
+
},
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
} catch (error) {
|
|
434
|
+
this.messageChannel?.port1.postMessage({
|
|
435
|
+
type: 'response',
|
|
436
|
+
id,
|
|
437
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Handle a streaming request - sends chunks as they arrive
|
|
445
|
+
*/
|
|
446
|
+
/**
|
|
447
|
+
* A page-side request streamed to the caller as it arrives, the door a host
|
|
448
|
+
* reads a guest's server through when it wants chunks rather than a body:
|
|
449
|
+
* a guest's own server is reached over the loopback as any client reaches
|
|
450
|
+
* it; a server the host registered answers through its own streaming
|
|
451
|
+
* method where it has one, else its buffered answer is delivered whole.
|
|
452
|
+
*/
|
|
453
|
+
async handleStreamingRequest(
|
|
454
|
+
port: number,
|
|
455
|
+
method: string,
|
|
456
|
+
url: string,
|
|
457
|
+
headers: Record<string, string>,
|
|
458
|
+
body: ArrayBuffer | undefined,
|
|
459
|
+
callbacks: {
|
|
460
|
+
start(statusCode: number, statusMessage: string, headers: Record<string, string>): void;
|
|
461
|
+
chunk(chunk: Uint8Array): void;
|
|
462
|
+
end(): void;
|
|
463
|
+
},
|
|
464
|
+
flow?: LoopbackStreamFlow,
|
|
465
|
+
): Promise<boolean> {
|
|
466
|
+
const virtualServer = this.servers.get(port);
|
|
467
|
+
if (!virtualServer) return false;
|
|
468
|
+
const bodyBuffer = body ? this.bodyOf(new Uint8Array(body)) : undefined;
|
|
469
|
+
// A guest's listener (the old null sentinel, now a request adapter) is
|
|
470
|
+
// streamed off its connection; its adapter would answer whole.
|
|
471
|
+
if (this.isGuest(virtualServer)) {
|
|
472
|
+
const named = Object.keys(headers).some((name) => name.toLowerCase() === 'host');
|
|
473
|
+
const authority = `${virtualServer.hostname && virtualServer.hostname !== '0.0.0.0' && virtualServer.hostname !== '::' ? virtualServer.hostname : '127.0.0.1'}:${port}`;
|
|
474
|
+
await this.streamGuest(
|
|
475
|
+
port, method, url, named ? headers : { ...headers, host: authority }, bodyBuffer,
|
|
476
|
+
(statusCode, statusMessage, respHeaders) => callbacks.start(statusCode, statusMessage, respHeaders),
|
|
477
|
+
(chunk) => callbacks.chunk(ownedBytes(chunk)),
|
|
478
|
+
() => callbacks.end(),
|
|
479
|
+
flow,
|
|
480
|
+
);
|
|
481
|
+
return true;
|
|
482
|
+
}
|
|
483
|
+
const server = virtualServer.server as IVirtualServer & {
|
|
484
|
+
handleStreamingRequest?: (
|
|
485
|
+
method: string, url: string, headers: Record<string, string>, body: Uint8Array | undefined,
|
|
486
|
+
onStart: (statusCode: number, statusMessage: string, headers: Record<string, string>) => void,
|
|
487
|
+
onChunk: (chunk: string | Uint8Array) => void,
|
|
488
|
+
onEnd: () => void,
|
|
489
|
+
flow?: LoopbackStreamFlow,
|
|
490
|
+
) => Promise<void>;
|
|
491
|
+
};
|
|
492
|
+
if (typeof server.handleStreamingRequest === 'function') {
|
|
493
|
+
// a registered server that streams is paced the same way: it is handed
|
|
494
|
+
// the reader's going away and the pause and resume of its own producer
|
|
495
|
+
await server.handleStreamingRequest(
|
|
496
|
+
method, url, headers, bodyBuffer,
|
|
497
|
+
(statusCode, statusMessage, respHeaders) => callbacks.start(statusCode, statusMessage, respHeaders),
|
|
498
|
+
(chunk) => callbacks.chunk(typeof chunk === 'string' ? _encoder.encode(chunk) : ownedBytes(chunk)),
|
|
499
|
+
() => callbacks.end(),
|
|
500
|
+
flow,
|
|
501
|
+
);
|
|
502
|
+
return true;
|
|
503
|
+
}
|
|
504
|
+
const response = await this.handleRequest(port, method, url, headers, body);
|
|
505
|
+
callbacks.start(response.statusCode, response.statusMessage ?? '', response.headers as Record<string, string>);
|
|
506
|
+
if (response.body) {
|
|
507
|
+
const body = typeof response.body === 'string' ? _encoder.encode(response.body) : response.body;
|
|
508
|
+
callbacks.chunk(ownedBytes(body));
|
|
509
|
+
}
|
|
510
|
+
callbacks.end();
|
|
511
|
+
return true;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
private async streamToServiceWorker(
|
|
515
|
+
id: number,
|
|
516
|
+
port: number,
|
|
517
|
+
method: string,
|
|
518
|
+
url: string,
|
|
519
|
+
headers: Record<string, string>,
|
|
520
|
+
body?: ArrayBuffer
|
|
521
|
+
): Promise<void> {
|
|
522
|
+
const virtualServer = this.servers.get(port);
|
|
523
|
+
|
|
524
|
+
if (!virtualServer) {
|
|
525
|
+
this.messageChannel?.port1.postMessage({
|
|
526
|
+
type: 'stream-start',
|
|
527
|
+
id,
|
|
528
|
+
data: { statusCode: 503, statusMessage: 'Service Unavailable', headers: {} },
|
|
529
|
+
});
|
|
530
|
+
this.messageChannel?.port1.postMessage({ type: 'stream-end', id });
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// A guest's server is a port: its answer is streamed off the connection
|
|
535
|
+
// as it arrives, which is what the page reads chunk by chunk.
|
|
536
|
+
if (this.isGuest(virtualServer)) {
|
|
537
|
+
const bodyBuffer = body ? this.bodyOf(new Uint8Array(body)) : undefined;
|
|
538
|
+
const named = Object.keys(headers).some((name) => name.toLowerCase() === 'host');
|
|
539
|
+
const authority = `${virtualServer.hostname && virtualServer.hostname !== '0.0.0.0' && virtualServer.hostname !== '::' ? virtualServer.hostname : '127.0.0.1'}:${port}`;
|
|
540
|
+
await this.streamGuest(
|
|
541
|
+
port, method, url, named ? headers : { ...headers, host: authority }, bodyBuffer,
|
|
542
|
+
(statusCode, statusMessage, respHeaders) => {
|
|
543
|
+
this.messageChannel?.port1.postMessage({ type: 'stream-start', id, data: { statusCode, statusMessage, headers: respHeaders } });
|
|
544
|
+
},
|
|
545
|
+
(chunk) => {
|
|
546
|
+
// A pooled Buffer's `.slice()` is a window on the same 8 KB; the
|
|
547
|
+
// transfer of `copy.buffer` would move the pool. These are the
|
|
548
|
+
// bytes the view names, in a buffer of their own.
|
|
549
|
+
const copy = ownedBytes(chunk);
|
|
550
|
+
this.messageChannel?.port1.postMessage({ type: 'stream-chunk', id, data: copy.buffer }, [copy.buffer]);
|
|
551
|
+
},
|
|
552
|
+
() => { this.messageChannel?.port1.postMessage({ type: 'stream-end', id }); },
|
|
553
|
+
);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Check if the server supports streaming (has handleStreamingRequest method)
|
|
558
|
+
const server = virtualServer.server as any;
|
|
559
|
+
if (!server) return;
|
|
560
|
+
if (typeof server.handleStreamingRequest === 'function') {
|
|
561
|
+
PortBridge.DEBUG && console.log('[ServerBridge] 🟢 Server has streaming support, calling handleStreamingRequest');
|
|
562
|
+
// Use streaming handler
|
|
563
|
+
const bodyBuffer = body ? this.bodyOf(new Uint8Array(body)) : undefined;
|
|
564
|
+
|
|
565
|
+
await server.handleStreamingRequest(
|
|
566
|
+
method,
|
|
567
|
+
url,
|
|
568
|
+
headers,
|
|
569
|
+
bodyBuffer,
|
|
570
|
+
// onStart - called with headers
|
|
571
|
+
(statusCode: number, statusMessage: string, respHeaders: Record<string, string>) => {
|
|
572
|
+
PortBridge.DEBUG && console.log('[ServerBridge] 🟢 onStart called, sending stream-start');
|
|
573
|
+
this.messageChannel?.port1.postMessage({
|
|
574
|
+
type: 'stream-start',
|
|
575
|
+
id,
|
|
576
|
+
data: { statusCode, statusMessage, headers: respHeaders },
|
|
577
|
+
});
|
|
578
|
+
},
|
|
579
|
+
// onChunk - called for each chunk
|
|
580
|
+
(chunk: string | Uint8Array) => {
|
|
581
|
+
const bytes = typeof chunk === 'string' ? _encoder.encode(chunk) : chunk;
|
|
582
|
+
const chunkBase64 = uint8ToBase64(bytes);
|
|
583
|
+
PortBridge.DEBUG && console.log('[ServerBridge] 🟡 onChunk called, sending stream-chunk, size:', chunkBase64.length);
|
|
584
|
+
this.messageChannel?.port1.postMessage({
|
|
585
|
+
type: 'stream-chunk',
|
|
586
|
+
id,
|
|
587
|
+
data: { chunkBase64 },
|
|
588
|
+
});
|
|
589
|
+
},
|
|
590
|
+
// onEnd - called when response is complete
|
|
591
|
+
() => {
|
|
592
|
+
PortBridge.DEBUG && console.log('[ServerBridge] 🟢 onEnd called, sending stream-end');
|
|
593
|
+
this.messageChannel?.port1.postMessage({ type: 'stream-end', id });
|
|
594
|
+
}
|
|
595
|
+
);
|
|
596
|
+
} else {
|
|
597
|
+
// Fall back to regular request handling
|
|
598
|
+
const bodyBuffer = body ? this.bodyOf(new Uint8Array(body)) : undefined;
|
|
599
|
+
const response = await server.handleRequest(method, url, headers, bodyBuffer);
|
|
600
|
+
|
|
601
|
+
// Send as a single stream
|
|
602
|
+
this.messageChannel?.port1.postMessage({
|
|
603
|
+
type: 'stream-start',
|
|
604
|
+
id,
|
|
605
|
+
data: {
|
|
606
|
+
statusCode: response.statusCode,
|
|
607
|
+
statusMessage: response.statusMessage,
|
|
608
|
+
headers: response.headers,
|
|
609
|
+
},
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
if (response.body && response.body.length > 0) {
|
|
613
|
+
const bytes = response.body instanceof Uint8Array ? response.body : new Uint8Array(0);
|
|
614
|
+
this.messageChannel?.port1.postMessage({
|
|
615
|
+
type: 'stream-chunk',
|
|
616
|
+
id,
|
|
617
|
+
data: { chunkBase64: uint8ToBase64(bytes) },
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
this.messageChannel?.port1.postMessage({ type: 'stream-end', id });
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Send message to Service Worker
|
|
627
|
+
*/
|
|
628
|
+
private notifyServiceWorker(type: string, data: unknown): void {
|
|
629
|
+
if (this.serviceWorkerReady && this.messageChannel) {
|
|
630
|
+
// Every port this bridge answers speaks the worker's flow-controlled
|
|
631
|
+
// stream: a response is paced by the reader's pulls, one chunk a pull,
|
|
632
|
+
// and a reader that goes away ends the request (virtual HTTP flow
|
|
633
|
+
// control, the worker's side of which is in public/__sw__.js).
|
|
634
|
+
const flow = type === 'server-registered'
|
|
635
|
+
? { flowControl: 1, maxRequestBytes: FLOW_MAX_REQUEST_BYTES, maxChunkBytes: FLOW_MAX_CHUNK_BYTES } : {};
|
|
636
|
+
this.messageChannel.port1.postMessage({ type, data: { ...(data as object), ...flow } });
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** Flow-controlled requests in flight: a pull is one chunk's credit, a cancel ends the upstream. */
|
|
641
|
+
private readonly controlledStreams = new Map<number, { pull(): void; cancel(): void }>();
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* A request the worker sent under flow control: the response starts with
|
|
645
|
+
* its head, then goes one chunk (at most FLOW_MAX_CHUNK_BYTES) for each
|
|
646
|
+
* `stream-pull`, and ends with `stream-end` once the server finished and
|
|
647
|
+
* every chunk went out. The upstream connection is paused while chunks wait
|
|
648
|
+
* for credit, so a slow reader holds the server back; `stream-cancel` ends it.
|
|
649
|
+
*/
|
|
650
|
+
private async controlledStream(id: number, port: number, method: string, url: string, headers: Record<string, string>, body?: ArrayBuffer): Promise<void> {
|
|
651
|
+
// the stream belongs to the channel it arrived on; a new channel's worker failed it already
|
|
652
|
+
const channel = this.messageChannel;
|
|
653
|
+
const post = (type: string, data?: unknown): void => { channel?.port1.postMessage({ type, id, ...(data === undefined ? {} : { data }) }); };
|
|
654
|
+
const queue: Uint8Array[] = [];
|
|
655
|
+
const abort = new AbortController();
|
|
656
|
+
let credits = 0;
|
|
657
|
+
let upstreamEnded = false;
|
|
658
|
+
let finished = false;
|
|
659
|
+
let paused = false;
|
|
660
|
+
let pause = (): void => undefined;
|
|
661
|
+
let resume = (): void => undefined;
|
|
662
|
+
const finish = (): void => { finished = true; this.controlledStreams.delete(id); };
|
|
663
|
+
const flush = (): void => {
|
|
664
|
+
if (finished) return;
|
|
665
|
+
while (credits > 0 && queue.length > 0) {
|
|
666
|
+
credits -= 1;
|
|
667
|
+
post('stream-chunk', { chunkBase64: uint8ToBase64(queue.shift()!) });
|
|
668
|
+
}
|
|
669
|
+
if (upstreamEnded && queue.length === 0) { post('stream-end'); finish(); return; }
|
|
670
|
+
// a few chunks may wait for credit; more than that, and the server waits
|
|
671
|
+
if (queue.length >= FLOW_QUEUED_CHUNKS && !paused) { paused = true; pause(); }
|
|
672
|
+
else if (queue.length < FLOW_QUEUED_CHUNKS && paused) { paused = false; resume(); }
|
|
673
|
+
};
|
|
674
|
+
this.controlledStreams.set(id, {
|
|
675
|
+
pull: () => { credits += 1; flush(); },
|
|
676
|
+
cancel: () => { if (finished) return; finish(); queue.length = 0; abort.abort(); },
|
|
677
|
+
});
|
|
678
|
+
try {
|
|
679
|
+
const answered = await this.handleStreamingRequest(port, method, url, headers, body, {
|
|
680
|
+
start: (statusCode, statusMessage, respHeaders) => post('stream-start', { statusCode, statusMessage, headers: respHeaders }),
|
|
681
|
+
chunk: (chunk) => {
|
|
682
|
+
if (finished) return;
|
|
683
|
+
for (let at = 0; at < chunk.byteLength; at += FLOW_MAX_CHUNK_BYTES) queue.push(chunk.slice(at, at + FLOW_MAX_CHUNK_BYTES));
|
|
684
|
+
flush();
|
|
685
|
+
},
|
|
686
|
+
end: () => { upstreamEnded = true; flush(); },
|
|
687
|
+
}, { signal: abort.signal, control: (p, r) => { pause = p; resume = r; } });
|
|
688
|
+
if (!answered) {
|
|
689
|
+
post('stream-start', { statusCode: 503, statusMessage: 'Service Unavailable', headers: {} });
|
|
690
|
+
post('stream-end');
|
|
691
|
+
finish();
|
|
692
|
+
}
|
|
693
|
+
} catch (error) {
|
|
694
|
+
if (!finished) { post('stream-error', { message: error instanceof Error ? error.message : String(error) }); finish(); }
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Create a mock request handler for testing without Service Worker
|
|
700
|
+
*/
|
|
701
|
+
createFetchHandler(): (request: Request) => Promise<Response> {
|
|
702
|
+
return async (request: Request): Promise<Response> => {
|
|
703
|
+
const url = new URL(request.url);
|
|
704
|
+
|
|
705
|
+
// Check if this is a virtual server request
|
|
706
|
+
const match = url.pathname.match(/^\/__virtual__\/(\d+)(\/.*)?$/);
|
|
707
|
+
if (!match) {
|
|
708
|
+
throw new Error('Not a virtual server request');
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const port = parseInt(match[1], 10);
|
|
712
|
+
const path = match[2] || '/';
|
|
713
|
+
|
|
714
|
+
// Build headers object
|
|
715
|
+
const headers: Record<string, string> = {};
|
|
716
|
+
request.headers.forEach((value, key) => {
|
|
717
|
+
headers[key] = value;
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
// Get body if present
|
|
721
|
+
let body: ArrayBuffer | undefined;
|
|
722
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
723
|
+
body = await request.arrayBuffer();
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Handle request
|
|
727
|
+
const response = await this.handleRequest(
|
|
728
|
+
port,
|
|
729
|
+
request.method,
|
|
730
|
+
path + url.search,
|
|
731
|
+
headers,
|
|
732
|
+
body
|
|
733
|
+
);
|
|
734
|
+
|
|
735
|
+
// Convert to fetch Response
|
|
736
|
+
// A header sent more than once, `set-cookie` above all, is appended as
|
|
737
|
+
// many times; a fetch Response takes each value on its own.
|
|
738
|
+
const answerHeaders = new Headers();
|
|
739
|
+
for (const [name, value] of Object.entries(response.headers)) {
|
|
740
|
+
for (const each of Array.isArray(value) ? value : [value]) answerHeaders.append(name, each);
|
|
741
|
+
}
|
|
742
|
+
return new Response(response.body as unknown as BodyInit, {
|
|
743
|
+
status: response.statusCode,
|
|
744
|
+
statusText: response.statusMessage ?? '',
|
|
745
|
+
headers: answerHeaders,
|
|
746
|
+
});
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// ---------------------------------------------------------------- an engine's own servers
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Whether a registration is a guest's own listener, answered over the
|
|
754
|
+
* engine's loopback rather than by a server object the host registered. A
|
|
755
|
+
* bridge with no engine in its realm has none: every server it holds was
|
|
756
|
+
* registered by the host, a worker's proxies among them.
|
|
757
|
+
*/
|
|
758
|
+
protected isGuest(entry: VirtualServer): boolean {
|
|
759
|
+
return entry.server === null;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/** A request to a guest's listener, over the engine's loopback. */
|
|
763
|
+
protected requestGuest(port: number, _method: string, _url: string, _headers: Record<string, string>, _body: Uint8Array | undefined): Promise<ResponseData> {
|
|
764
|
+
return Promise.reject(new Error(`Port ${port} has no server in this realm.`));
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/** A guest's answer streamed off its connection as it arrives. */
|
|
768
|
+
protected streamGuest(
|
|
769
|
+
port: number, _method: string, _url: string, _headers: Record<string, string>, _body: Uint8Array | undefined,
|
|
770
|
+
_onStart: (statusCode: number, statusMessage: string, headers: Record<string, string>) => void,
|
|
771
|
+
_onChunk: (chunk: Uint8Array) => void,
|
|
772
|
+
_onEnd: () => void,
|
|
773
|
+
_flow?: LoopbackStreamFlow,
|
|
774
|
+
): Promise<void> {
|
|
775
|
+
return Promise.reject(new Error(`Port ${port} has no server in this realm.`));
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** A request's body as the servers of this realm read it. */
|
|
779
|
+
protected bodyOf(bytes: Uint8Array): Uint8Array {
|
|
780
|
+
return bytes;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// The page's bridge: one per realm, as the service worker keeps one channel per page.
|
|
785
|
+
let globalPortBridge: PortBridge | null = null;
|
|
786
|
+
|
|
787
|
+
/** Get or create this realm's port bridge, for a page whose engine, if any, runs elsewhere. */
|
|
788
|
+
export function getPortBridge(options?: BridgeOptions): PortBridge {
|
|
789
|
+
if (!globalPortBridge) globalPortBridge = new PortBridge(options);
|
|
790
|
+
return globalPortBridge;
|
|
791
|
+
}
|