Skip to content

Commit b97d4a9

Browse files
committed
feat: US-191 - Add WebSocket project-matrix fixture using ws package
1 parent 181ff8c commit b97d4a9

11 files changed

Lines changed: 613 additions & 89 deletions

File tree

packages/secure-exec-core/isolate-runtime/src/common/runtime-globals.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ import type {
4646
ChildProcessSpawnSyncBridgeRef,
4747
ChildProcessStdinCloseBridgeRef,
4848
ChildProcessStdinWriteBridgeRef,
49+
UpgradeSocketWriteRawBridgeRef,
50+
UpgradeSocketEndRawBridgeRef,
51+
UpgradeSocketDestroyRawBridgeRef,
4952
} from "../../../src/shared/bridge-contract.js";
5053

5154
type RuntimeGlobalExposer = (name: string, value: unknown) => void;
@@ -94,6 +97,9 @@ declare global {
9497
var _networkHttpRequestRaw: NetworkHttpRequestRawBridgeRef;
9598
var _networkHttpServerListenRaw: NetworkHttpServerListenRawBridgeRef;
9699
var _networkHttpServerCloseRaw: NetworkHttpServerCloseRawBridgeRef;
100+
var _upgradeSocketWriteRaw: UpgradeSocketWriteRawBridgeRef;
101+
var _upgradeSocketEndRaw: UpgradeSocketEndRawBridgeRef;
102+
var _upgradeSocketDestroyRaw: UpgradeSocketDestroyRawBridgeRef;
97103
var _childProcessSpawnStart: ChildProcessSpawnStartBridgeRef;
98104
var _childProcessStdinWrite: ChildProcessStdinWriteBridgeRef;
99105
var _childProcessStdinClose: ChildProcessStdinCloseBridgeRef;

packages/secure-exec-core/src/bridge/network.ts

Lines changed: 235 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ import type {
1515
NetworkHttpServerListenRawBridgeRef,
1616
RegisterHandleBridgeFn,
1717
UnregisterHandleBridgeFn,
18+
UpgradeSocketWriteRawBridgeRef,
19+
UpgradeSocketEndRawBridgeRef,
20+
UpgradeSocketDestroyRawBridgeRef,
1821
} from "../shared/bridge-contract.js";
1922

2023
// Declare host bridge References
@@ -32,6 +35,18 @@ declare const _networkHttpServerCloseRaw:
3235
| NetworkHttpServerCloseRawBridgeRef
3336
| undefined;
3437

38+
declare const _upgradeSocketWriteRaw:
39+
| UpgradeSocketWriteRawBridgeRef
40+
| undefined;
41+
42+
declare const _upgradeSocketEndRaw:
43+
| UpgradeSocketEndRawBridgeRef
44+
| undefined;
45+
46+
declare const _upgradeSocketDestroyRaw:
47+
| UpgradeSocketDestroyRawBridgeRef
48+
| undefined;
49+
3550
declare const _registerHandle:
3651
| RegisterHandleBridgeFn
3752
| undefined;
@@ -745,15 +760,27 @@ export class ClientRequest {
745760
statusText?: string;
746761
body?: string;
747762
trailers?: Record<string, string>;
763+
upgradeSocketId?: number;
748764
};
749765

750766
this.finished = true;
751767

752768
// 101 Switching Protocols → fire 'upgrade' event
753769
if (response.status === 101) {
754770
const res = new IncomingMessage(response);
755-
const head = typeof Buffer !== "undefined" ? Buffer.alloc(0) : new Uint8Array(0);
756-
this._emit("upgrade", res, this.socket, head);
771+
// Use UpgradeSocket for bidirectional data relay when socketId is available
772+
let socket: FakeSocket | UpgradeSocket = this.socket;
773+
if (response.upgradeSocketId != null) {
774+
socket = new UpgradeSocket(response.upgradeSocketId, {
775+
host: this._options.hostname as string,
776+
port: Number(this._options.port) || 80,
777+
});
778+
upgradeSocketInstances.set(response.upgradeSocketId, socket);
779+
}
780+
const head = typeof Buffer !== "undefined"
781+
? (response.body ? Buffer.from(response.body, "base64") : Buffer.alloc(0))
782+
: new Uint8Array(0);
783+
this._emit("upgrade", res, socket, head);
757784
return;
758785
}
759786

@@ -1020,6 +1047,8 @@ const serverRequestListeners = new Map<
10201047
number,
10211048
(incoming: ServerIncomingMessage, outgoing: ServerResponseBridge) => unknown
10221049
>();
1050+
// Server instances indexed by serverId — used by upgrade dispatch to emit 'upgrade' events
1051+
const serverInstances = new Map<number, Server>();
10231052

10241053
class ServerIncomingMessage {
10251054
headers: Record<string, string>;
@@ -1344,9 +1373,11 @@ class Server {
13441373
} else {
13451374
serverRequestListeners.set(this._serverId, () => undefined);
13461375
}
1376+
serverInstances.set(this._serverId, this);
13471377
}
13481378

1349-
private _emit(event: string, ...args: unknown[]): void {
1379+
/** @internal Emit an event — used by upgrade dispatch to fire 'upgrade' events. */
1380+
_emit(event: string, ...args: unknown[]): void {
13501381
const listeners = this._listeners[event];
13511382
if (!listeners || listeners.length === 0) return;
13521383
listeners.slice().forEach((listener) => listener(...args));
@@ -1415,6 +1446,7 @@ class Server {
14151446
}
14161447
this.listening = false;
14171448
this._address = null;
1449+
serverInstances.delete(this._serverId);
14181450
if (this._handleId && typeof _unregisterHandle === "function") {
14191451
_unregisterHandle(this._handleId);
14201452
}
@@ -1534,6 +1566,203 @@ async function dispatchServerRequest(
15341566
return JSON.stringify(outgoing.serialize());
15351567
}
15361568

1569+
// Upgrade socket for bidirectional data relay through the host bridge
1570+
const upgradeSocketInstances = new Map<number, UpgradeSocket>();
1571+
1572+
class UpgradeSocket {
1573+
remoteAddress: string;
1574+
remotePort: number;
1575+
localAddress = "127.0.0.1";
1576+
localPort = 0;
1577+
connecting = false;
1578+
destroyed = false;
1579+
writable = true;
1580+
readable = true;
1581+
readyState = "open";
1582+
bytesWritten = 0;
1583+
private _listeners: Record<string, EventListener[]> = {};
1584+
private _socketId: number;
1585+
1586+
// Readable stream state stub for ws compatibility (socketOnClose checks _readableState.endEmitted)
1587+
_readableState = { endEmitted: false };
1588+
_writableState = { finished: false, errorEmitted: false };
1589+
1590+
constructor(socketId: number, options?: { host?: string; port?: number }) {
1591+
this._socketId = socketId;
1592+
this.remoteAddress = options?.host || "127.0.0.1";
1593+
this.remotePort = options?.port || 80;
1594+
}
1595+
1596+
setTimeout(_ms: number, _cb?: () => void): this { return this; }
1597+
setNoDelay(_noDelay?: boolean): this { return this; }
1598+
setKeepAlive(_enable?: boolean, _delay?: number): this { return this; }
1599+
ref(): this { return this; }
1600+
unref(): this { return this; }
1601+
cork(): void {}
1602+
uncork(): void {}
1603+
pause(): this { return this; }
1604+
resume(): this { return this; }
1605+
address(): { address: string; family: string; port: number } {
1606+
return { address: this.localAddress, family: "IPv4", port: this.localPort };
1607+
}
1608+
1609+
on(event: string, listener: EventListener): this {
1610+
if (!this._listeners[event]) this._listeners[event] = [];
1611+
this._listeners[event].push(listener);
1612+
return this;
1613+
}
1614+
1615+
addListener(event: string, listener: EventListener): this {
1616+
return this.on(event, listener);
1617+
}
1618+
1619+
once(event: string, listener: EventListener): this {
1620+
const wrapper = (...args: unknown[]): void => {
1621+
this.off(event, wrapper);
1622+
listener(...args);
1623+
};
1624+
return this.on(event, wrapper);
1625+
}
1626+
1627+
off(event: string, listener: EventListener): this {
1628+
if (this._listeners[event]) {
1629+
const idx = this._listeners[event].indexOf(listener);
1630+
if (idx !== -1) this._listeners[event].splice(idx, 1);
1631+
}
1632+
return this;
1633+
}
1634+
1635+
removeListener(event: string, listener: EventListener): this {
1636+
return this.off(event, listener);
1637+
}
1638+
1639+
removeAllListeners(event?: string): this {
1640+
if (event) {
1641+
delete this._listeners[event];
1642+
} else {
1643+
this._listeners = {};
1644+
}
1645+
return this;
1646+
}
1647+
1648+
emit(event: string, ...args: unknown[]): boolean {
1649+
const handlers = this._listeners[event];
1650+
if (handlers) handlers.slice().forEach((fn) => fn.call(this, ...args));
1651+
return handlers !== undefined && handlers.length > 0;
1652+
}
1653+
1654+
listenerCount(event: string): number {
1655+
return this._listeners[event]?.length || 0;
1656+
}
1657+
1658+
// Allow arbitrary property assignment (used by ws for Symbol properties)
1659+
[key: string | symbol]: unknown;
1660+
1661+
write(data: unknown, encodingOrCb?: string | (() => void), cb?: (() => void)): boolean {
1662+
if (this.destroyed) return false;
1663+
const callback = typeof encodingOrCb === "function" ? encodingOrCb : cb;
1664+
if (typeof _upgradeSocketWriteRaw !== "undefined") {
1665+
let base64: string;
1666+
if (typeof Buffer !== "undefined" && Buffer.isBuffer(data)) {
1667+
base64 = data.toString("base64");
1668+
} else if (typeof data === "string") {
1669+
base64 = typeof Buffer !== "undefined" ? Buffer.from(data).toString("base64") : btoa(data);
1670+
} else if (data instanceof Uint8Array) {
1671+
base64 = typeof Buffer !== "undefined" ? Buffer.from(data).toString("base64") : btoa(String.fromCharCode(...data));
1672+
} else {
1673+
base64 = typeof Buffer !== "undefined" ? Buffer.from(String(data)).toString("base64") : btoa(String(data));
1674+
}
1675+
this.bytesWritten += base64.length;
1676+
_upgradeSocketWriteRaw.applySync(undefined, [this._socketId, base64]);
1677+
}
1678+
if (callback) callback();
1679+
return true;
1680+
}
1681+
1682+
end(data?: unknown): this {
1683+
if (data) this.write(data);
1684+
if (typeof _upgradeSocketEndRaw !== "undefined" && !this.destroyed) {
1685+
_upgradeSocketEndRaw.applySync(undefined, [this._socketId]);
1686+
}
1687+
this.writable = false;
1688+
this.emit("finish");
1689+
return this;
1690+
}
1691+
1692+
destroy(err?: Error): this {
1693+
if (this.destroyed) return this;
1694+
this.destroyed = true;
1695+
this.writable = false;
1696+
this.readable = false;
1697+
this._readableState.endEmitted = true;
1698+
this._writableState.finished = true;
1699+
if (typeof _upgradeSocketDestroyRaw !== "undefined") {
1700+
_upgradeSocketDestroyRaw.applySync(undefined, [this._socketId]);
1701+
}
1702+
upgradeSocketInstances.delete(this._socketId);
1703+
if (err) this.emit("error", err);
1704+
this.emit("close", false);
1705+
return this;
1706+
}
1707+
1708+
// Push data received from the host into this socket
1709+
_pushData(data: Buffer | Uint8Array): void {
1710+
this.emit("data", data);
1711+
}
1712+
1713+
// Signal end-of-stream from the host
1714+
_pushEnd(): void {
1715+
this.readable = false;
1716+
this._readableState.endEmitted = true;
1717+
this._writableState.finished = true;
1718+
this.emit("end");
1719+
this.emit("close", false);
1720+
upgradeSocketInstances.delete(this._socketId);
1721+
}
1722+
}
1723+
1724+
/** Route an incoming HTTP upgrade to the server's 'upgrade' event listeners. */
1725+
function dispatchUpgradeRequest(
1726+
serverId: number,
1727+
requestJson: string,
1728+
headBase64: string,
1729+
socketId: number
1730+
): void {
1731+
const server = serverInstances.get(serverId);
1732+
if (!server) {
1733+
throw new Error(`Unknown HTTP server for upgrade: ${serverId}`);
1734+
}
1735+
1736+
const request = JSON.parse(requestJson) as SerializedServerRequest;
1737+
const incoming = new ServerIncomingMessage(request);
1738+
const head = typeof Buffer !== "undefined" ? Buffer.from(headBase64, "base64") : new Uint8Array(0);
1739+
1740+
const socket = new UpgradeSocket(socketId, {
1741+
host: incoming.headers["host"]?.split(":")[0] || "127.0.0.1",
1742+
});
1743+
upgradeSocketInstances.set(socketId, socket);
1744+
1745+
// Emit 'upgrade' on the server — ws.WebSocketServer listens for this
1746+
server._emit("upgrade", incoming, socket, head);
1747+
}
1748+
1749+
/** Push data from host to an upgrade socket. */
1750+
function onUpgradeSocketData(socketId: number, dataBase64: string): void {
1751+
const socket = upgradeSocketInstances.get(socketId);
1752+
if (socket) {
1753+
const data = typeof Buffer !== "undefined" ? Buffer.from(dataBase64, "base64") : new Uint8Array(0);
1754+
socket._pushData(data);
1755+
}
1756+
}
1757+
1758+
/** Signal end-of-stream from host to an upgrade socket. */
1759+
function onUpgradeSocketEnd(socketId: number): void {
1760+
const socket = upgradeSocketInstances.get(socketId);
1761+
if (socket) {
1762+
socket._pushEnd();
1763+
}
1764+
}
1765+
15371766
// Function-based ServerResponse constructor — allows .call() inheritance
15381767
// used by light-my-request (Fastify's inject), which does
15391768
// http.ServerResponse.call(this, req) + util.inherits(Response, http.ServerResponse)
@@ -1693,6 +1922,9 @@ exposeCustomGlobal("_httpsModule", https);
16931922
exposeCustomGlobal("_http2Module", http2);
16941923
exposeCustomGlobal("_dnsModule", dns);
16951924
exposeCustomGlobal("_httpServerDispatch", dispatchServerRequest);
1925+
exposeCustomGlobal("_httpServerUpgradeDispatch", dispatchUpgradeRequest);
1926+
exposeCustomGlobal("_upgradeSocketData", onUpgradeSocketData);
1927+
exposeCustomGlobal("_upgradeSocketEnd", onUpgradeSocketEnd);
16961928

16971929
// Harden fetch API globals (non-writable, non-configurable)
16981930
exposeCustomGlobal("fetch", fetch);

packages/secure-exec-core/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,9 @@ export type {
148148
NetworkHttpRequestRawBridgeRef,
149149
NetworkHttpServerCloseRawBridgeRef,
150150
NetworkHttpServerListenRawBridgeRef,
151+
UpgradeSocketWriteRawBridgeRef,
152+
UpgradeSocketEndRawBridgeRef,
153+
UpgradeSocketDestroyRawBridgeRef,
151154
ProcessErrorBridgeRef,
152155
ProcessLogBridgeRef,
153156
RegisterHandleBridgeFn,

packages/secure-exec-core/src/shared/bridge-contract.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ export const HOST_BRIDGE_GLOBAL_KEYS = {
6363
networkHttpRequestRaw: "_networkHttpRequestRaw",
6464
networkHttpServerListenRaw: "_networkHttpServerListenRaw",
6565
networkHttpServerCloseRaw: "_networkHttpServerCloseRaw",
66+
upgradeSocketWriteRaw: "_upgradeSocketWriteRaw",
67+
upgradeSocketEndRaw: "_upgradeSocketEndRaw",
68+
upgradeSocketDestroyRaw: "_upgradeSocketDestroyRaw",
6669
ptySetRawMode: "_ptySetRawMode",
6770
processConfig: "_processConfig",
6871
osConfig: "_osConfig",
@@ -85,6 +88,9 @@ export const RUNTIME_BRIDGE_GLOBAL_KEYS = {
8588
http2Module: "_http2Module",
8689
dnsModule: "_dnsModule",
8790
httpServerDispatch: "_httpServerDispatch",
91+
httpServerUpgradeDispatch: "_httpServerUpgradeDispatch",
92+
upgradeSocketData: "_upgradeSocketData",
93+
upgradeSocketEnd: "_upgradeSocketEnd",
8894
fsFacade: "_fs",
8995
requireFrom: "_requireFrom",
9096
moduleCache: "_moduleCache",
@@ -246,6 +252,9 @@ export type NetworkDnsLookupRawBridgeRef = BridgeApplyRef<[string], string>;
246252
export type NetworkHttpRequestRawBridgeRef = BridgeApplyRef<[string, string], string>;
247253
export type NetworkHttpServerListenRawBridgeRef = BridgeApplyRef<[string], string>;
248254
export type NetworkHttpServerCloseRawBridgeRef = BridgeApplyRef<[number], void>;
255+
export type UpgradeSocketWriteRawBridgeRef = BridgeApplySyncRef<[number, string], void>;
256+
export type UpgradeSocketEndRawBridgeRef = BridgeApplySyncRef<[number], void>;
257+
export type UpgradeSocketDestroyRawBridgeRef = BridgeApplySyncRef<[number], void>;
249258

250259
// PTY boundary contracts.
251260
export type PtySetRawModeBridgeRef = BridgeApplySyncRef<[boolean], void>;

0 commit comments

Comments
 (0)