/** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ export type WebSocketMessage = string | ArrayBufferLike | Blob | ArrayBufferView; export type WSData = { data: string, isBase64: boolean }; export type OnCreatePayload = { type: 'onCreate', id: string, url: string }; export type OnMessageFromPagePayload = { type: 'onMessageFromPage', id: string, data: WSData }; export type OnClosePagePayload = { type: 'onClosePage', id: string, code: number | undefined, reason: string | undefined, wasClean: boolean }; export type OnMessageFromServerPayload = { type: 'onMessageFromServer', id: string, data: WSData }; export type OnCloseServerPayload = { type: 'onCloseServer', id: string, code: number | undefined, reason: string | undefined, wasClean: boolean }; export type BindingPayload = OnCreatePayload | OnMessageFromPagePayload | OnMessageFromServerPayload | OnClosePagePayload | OnCloseServerPayload; export type ConnectRequest = { type: 'connect', id: string }; export type PassthroughRequest = { type: 'passthrough', id: string }; export type EnsureOpenedRequest = { type: 'ensureOpened', id: string }; export type SendToPageRequest = { type: 'sendToPage', id: string, data: WSData }; export type SendToServerRequest = { type: 'sendToServer', id: string, data: WSData }; export type ClosePageRequest = { type: 'closePage', id: string, code: number | undefined, reason: string | undefined, wasClean: boolean }; export type CloseServerRequest = { type: 'closeServer', id: string, code: number | undefined, reason: string | undefined, wasClean: boolean }; export type APIRequest = ConnectRequest | PassthroughRequest | EnsureOpenedRequest | SendToPageRequest | SendToServerRequest | ClosePageRequest | CloseServerRequest; // eslint-disable-next-line no-restricted-globals type GlobalThis = typeof globalThis; export function inject(globalThis: GlobalThis) { if ((globalThis as any).__pwWebSocketDispatch) return; function generateId() { const bytes = new Uint8Array(32); globalThis.crypto.getRandomValues(bytes); const hex = '0123456789abcdef'; return [...bytes].map(value => { const high = Math.floor(value / 16); const low = value % 16; return hex[high] + hex[low]; }).join(''); } function bufferToData(b: Uint8Array): WSData { let s = ''; for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]); return { data: globalThis.btoa(s), isBase64: true }; } function stringToBuffer(s: string): ArrayBuffer { s = globalThis.atob(s); const b = new Uint8Array(s.length); for (let i = 0; i < s.length; i++) b[i] = s.charCodeAt(i); return b.buffer; } // Note: this function tries to be synchronous when it can to preserve the ability to send // multiple messages synchronously in the same order and then synchronously close. function messageToData(message: WebSocketMessage, cb: (data: WSData) => any) { if (message instanceof globalThis.Blob) return message.arrayBuffer().then(buffer => cb(bufferToData(new Uint8Array(buffer)))); if (typeof message === 'string') return cb({ data: message, isBase64: false }); if (ArrayBuffer.isView(message)) return cb(bufferToData(new Uint8Array(message.buffer, message.byteOffset, message.byteLength))); return cb(bufferToData(new Uint8Array(message))); } function dataToMessage(data: WSData, binaryType: 'blob' | 'arraybuffer'): WebSocketMessage { if (!data.isBase64) return data.data; const buffer = stringToBuffer(data.data); return binaryType === 'arraybuffer' ? buffer : new Blob([buffer]); } const binding = (globalThis as any).__pwWebSocketBinding as (message: BindingPayload) => void; const NativeWebSocket: typeof WebSocket = globalThis.WebSocket; const idToWebSocket = new Map(); (globalThis as any).__pwWebSocketDispatch = (request: APIRequest) => { const ws = idToWebSocket.get(request.id); if (!ws) return; if (request.type === 'connect') ws._apiConnect(); if (request.type === 'passthrough') ws._apiPassThrough(); if (request.type === 'ensureOpened') ws._apiEnsureOpened(); if (request.type === 'sendToPage') ws._apiSendToPage(dataToMessage(request.data, ws.binaryType)); if (request.type === 'closePage') ws._apiClosePage(request.code, request.reason, request.wasClean); if (request.type === 'sendToServer') ws._apiSendToServer(dataToMessage(request.data, ws.binaryType)); if (request.type === 'closeServer') ws._apiCloseServer(request.code, request.reason, request.wasClean); }; class WebSocketMock extends EventTarget { static readonly CONNECTING: 0 = 0; // WebSocket.CONNECTING static readonly OPEN: 1 = 1; // WebSocket.OPEN static readonly CLOSING: 2 = 2; // WebSocket.CLOSING static readonly CLOSED: 3 = 3; // WebSocket.CLOSED CONNECTING: 0 = 0; // WebSocket.CONNECTING OPEN: 1 = 1; // WebSocket.OPEN CLOSING: 2 = 2; // WebSocket.CLOSING CLOSED: 3 = 3; // WebSocket.CLOSED private _oncloseListener: WebSocket['onclose'] = null; private _onerrorListener: WebSocket['onerror'] = null; private _onmessageListener: WebSocket['onmessage'] = null; private _onopenListener: WebSocket['onopen'] = null; bufferedAmount: number = 0; extensions: string = ''; protocol: string = ''; readyState: number = 0; readonly url: string; private _id: string; private _origin: string = ''; private _protocols?: string | string[]; private _ws?: WebSocket; private _passthrough = false; private _wsBufferedMessages: WebSocketMessage[] = []; private _binaryType: BinaryType = 'blob'; constructor(url: string | URL, protocols?: string | string[]) { super(); this.url = typeof url === 'string' ? url : url.href; try { this.url = new URL(url).href; this._origin = new URL(url).origin; } catch { } this._protocols = protocols; this._id = generateId(); idToWebSocket.set(this._id, this); binding({ type: 'onCreate', id: this._id, url: this.url }); } // --- native WebSocket implementation --- get binaryType() { return this._binaryType; } set binaryType(type) { this._binaryType = type; if (this._ws) this._ws.binaryType = type; } get onclose() { return this._oncloseListener; } set onclose(listener) { if (this._oncloseListener) this.removeEventListener('close', this._oncloseListener as any); this._oncloseListener = listener; if (this._oncloseListener) this.addEventListener('close', this._oncloseListener as any); } get onerror() { return this._onerrorListener; } set onerror(listener) { if (this._onerrorListener) this.removeEventListener('error', this._onerrorListener); this._onerrorListener = listener; if (this._onerrorListener) this.addEventListener('error', this._onerrorListener); } get onopen() { return this._onopenListener; } set onopen(listener) { if (this._onopenListener) this.removeEventListener('open', this._onopenListener); this._onopenListener = listener; if (this._onopenListener) this.addEventListener('open', this._onopenListener); } get onmessage() { return this._onmessageListener; } set onmessage(listener) { if (this._onmessageListener) this.removeEventListener('message', this._onmessageListener as any); this._onmessageListener = listener; if (this._onmessageListener) this.addEventListener('message', this._onmessageListener as any); } send(message: WebSocketMessage): void { if (this.readyState === WebSocketMock.CONNECTING) throw new DOMException(`Failed to execute 'send' on 'WebSocket': Still in CONNECTING state.`); if (this.readyState !== WebSocketMock.OPEN) throw new DOMException(`WebSocket is already in CLOSING or CLOSED state.`); if (this._passthrough) { if (this._ws) this._apiSendToServer(message); } else { messageToData(message, data => binding({ type: 'onMessageFromPage', id: this._id, data })); } } close(code?: number, reason?: string): void { if (code !== undefined && code !== 1000 && (code < 3000 || code > 4999)) throw new DOMException(`Failed to execute 'close' on 'WebSocket': The close code must be either 1000, or between 3000 and 4999. ${code} is neither.`); if (this.readyState === WebSocketMock.OPEN || this.readyState === WebSocketMock.CONNECTING) this.readyState = WebSocketMock.CLOSING; if (this._passthrough) this._apiCloseServer(code, reason, true); else binding({ type: 'onClosePage', id: this._id, code, reason, wasClean: true }); } // --- methods called from the routing API --- _apiEnsureOpened() { // This is called at the end of the route handler. If we did not connect to the server, // assume that websocket will be fully mocked. In this case, pretend that server // connection is established right away. if (!this._ws) this._ensureOpened(); } _apiSendToPage(message: WebSocketMessage) { // Calling "sendToPage()" from the route handler. Allow this for easier testing. this._ensureOpened(); if (this.readyState !== WebSocketMock.OPEN) throw new DOMException(`WebSocket is already in CLOSING or CLOSED state.`); this.dispatchEvent(new MessageEvent('message', { data: message, origin: this._origin, cancelable: true })); } _apiSendToServer(message: WebSocketMessage) { if (!this._ws) throw new Error('Cannot send a message before connecting to the server'); if (this._ws.readyState === WebSocketMock.CONNECTING) this._wsBufferedMessages.push(message); else this._ws.send(message); } _apiConnect() { if (this._ws) throw new Error('Can only connect to the server once'); this._ws = new NativeWebSocket(this.url, this._protocols); this._ws.binaryType = this._binaryType; this._ws.onopen = () => { for (const message of this._wsBufferedMessages) this._ws!.send(message); this._wsBufferedMessages = []; this._ensureOpened(); }; this._ws.onclose = event => { this._onWSClose(event.code, event.reason, event.wasClean); }; this._ws.onmessage = event => { if (this._passthrough) this._apiSendToPage(event.data); else messageToData(event.data, data => binding({ type: 'onMessageFromServer', id: this._id, data })); }; this._ws.onerror = () => { // We do not expose errors in the API, so short-curcuit the error event. const event = new Event('error', { cancelable: true }); this.dispatchEvent(event); }; } // This method connects to the server, and passes all messages through, // as if WebSocketMock was not engaged. _apiPassThrough() { this._passthrough = true; this._apiConnect(); } _apiCloseServer(code: number | undefined, reason: string | undefined, wasClean: boolean) { if (!this._ws) { // Short-curcuit when there is no server. this._onWSClose(code, reason, wasClean); return; } if (this._ws.readyState === WebSocketMock.CONNECTING || this._ws.readyState === WebSocketMock.OPEN) this._ws.close(code, reason); } _apiClosePage(code: number | undefined, reason: string | undefined, wasClean: boolean) { if (this.readyState === WebSocketMock.CLOSED) return; this.readyState = WebSocketMock.CLOSED; this.dispatchEvent(new CloseEvent('close', { code, reason, wasClean, cancelable: true })); this._maybeCleanup(); if (this._passthrough) this._apiCloseServer(code, reason, wasClean); else binding({ type: 'onClosePage', id: this._id, code, reason, wasClean }); } // --- internals --- _ensureOpened() { if (this.readyState !== WebSocketMock.CONNECTING) return; this.extensions = this._ws?.extensions || ''; if (this._ws) this.protocol = this._ws.protocol; else if (Array.isArray(this._protocols)) this.protocol = this._protocols[0] || ''; else this.protocol = this._protocols || ''; this.readyState = WebSocketMock.OPEN; this.dispatchEvent(new Event('open', { cancelable: true })); } private _onWSClose(code: number | undefined, reason: string | undefined, wasClean: boolean) { if (this._passthrough) this._apiClosePage(code, reason, wasClean); else binding({ type: 'onCloseServer', id: this._id, code, reason, wasClean }); if (this._ws) { this._ws.onopen = null; this._ws.onclose = null; this._ws.onmessage = null; this._ws.onerror = null; this._ws = undefined; this._wsBufferedMessages = []; } this._maybeCleanup(); } private _maybeCleanup() { if (this.readyState === WebSocketMock.CLOSED && !this._ws) idToWebSocket.delete(this._id); } } globalThis.WebSocket = class WebSocket extends WebSocketMock {}; }