Introduce GristSocketServer, GristServerSocket and GristClientSocket

These classes, used as an alternative to native WebSockets,
provide an automatic fallback to HTTP long polling (implemented
using Engine.IO) when a WebSocket connection fails.
pull/859/head
Jonathan Perret 4 months ago
parent a354b9be69
commit ea08b4908f

@ -0,0 +1,150 @@
import WS from 'ws';
import {Socket as EIOSocket} from 'engine.io-client';
export interface GristClientSocketOptions {
headers?: Record<string, string>;
}
export class GristClientSocket {
private _wsSocket: WS.WebSocket | WebSocket | undefined;
private _eioSocket: EIOSocket | undefined;
// Set to true when the connection process is complete, either succesfully or
// after the WebSocket and polling transports have both failed. Events from
// the underlying socket are not forwarded to the client until that point.
private _openDone: boolean = false;
private _messageHandler: null | ((data: string) => void);
private _openHandler: null | (() => void);
private _errorHandler: null | ((err: Error) => void);
private _closeHandler: null | (() => void);
constructor(private _url: string, private _options?: GristClientSocketOptions) {
this._createWSSocket();
}
public set onmessage(cb: null | ((data: string) => void)) {
this._messageHandler = cb;
}
public set onopen(cb: null | (() => void)) {
this._openHandler = cb;
}
public set onerror(cb: null | ((err: Error) => void)) {
this._errorHandler = cb;
}
public set onclose(cb: null | (() => void)) {
this._closeHandler = cb;
}
public close() {
if (this._wsSocket) {
this._wsSocket.close();
} else {
this._eioSocket!.close();
}
}
public send(data: string) {
if (this._wsSocket) {
this._wsSocket.send(data);
} else {
this._eioSocket!.send(data);
}
}
// pause() and resume() are used for tests and assume a WS.WebSocket transport
public pause() {
(this._wsSocket as WS.WebSocket)?.pause();
}
public resume() {
(this._wsSocket as WS.WebSocket)?.resume();
}
private _createWSSocket() {
if(typeof WebSocket !== 'undefined') {
this._wsSocket = new WebSocket(this._url);
} else {
this._wsSocket = new WS(this._url, undefined, this._options);
}
this._wsSocket.onmessage = this._onWSMessage.bind(this);
this._wsSocket.onopen = this._onWSOpen.bind(this);
this._wsSocket.onerror = this._onWSError.bind(this);
this._wsSocket.onclose = this._onWSClose.bind(this);
}
private _destroyWSSocket() {
if (this._wsSocket) {
this._wsSocket.onmessage = null;
this._wsSocket.onopen = null;
this._wsSocket.onerror = null;
this._wsSocket.onclose = null;
this._wsSocket = undefined;
}
}
private _onWSMessage(event: WS.MessageEvent | MessageEvent<any>) {
if (this._openDone) {
// event.data is guaranteed to be a string here because we only send text frames.
// https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/message_event#event_properties
this._messageHandler?.(event.data);
}
}
private _onWSOpen() {
// The connection was established successfully. Any future events can now
// be forwarded to the client.
this._openDone = true;
this._openHandler?.();
}
private _onWSError(ev: Event) {
// The first connection attempt failed. Trigger an attempt with another transport.
this._destroyWSSocket();
this._createEIOSocket();
}
private _onWSClose() {
if (this._openDone) {
this._closeHandler?.();
}
}
private _createEIOSocket() {
this._eioSocket = new EIOSocket(this._url, {
path: new URL(this._url).pathname,
addTrailingSlash: false,
transports: ['polling'],
upgrade: false,
extraHeaders: this._options?.headers,
withCredentials: true,
});
this._eioSocket.on('message', this._onEIOMessage.bind(this));
this._eioSocket.on('open', this._onEIOOpen.bind(this));
this._eioSocket.on('error', this._onEIOError.bind(this));
this._eioSocket.on('close', this._onEIOClose.bind(this));
}
private _onEIOMessage(data: string) {
this._messageHandler?.(data);
}
private _onEIOOpen() {
this._openHandler?.();
}
private _onEIOError(ev: any) {
// We will make no further attempt to connect. Any future events can now
// be forwarded to the client.
this._openDone = true;
this._errorHandler?.(ev);
}
private _onEIOClose() {
this._closeHandler?.();
}
}

@ -9,6 +9,7 @@ import {addOrgToPath, docUrl, getGristConfig} from 'app/common/urlUtils';
import {UserAPI} from 'app/common/UserAPI';
import {Events as BackboneEvents} from 'backbone';
import {Disposable} from 'grainjs';
import {GristClientSocket} from './GristClientSocket';
const G = getBrowserGlobals('window');
const reconnectInterval = [1000, 1000, 2000, 5000, 10000];
@ -37,7 +38,7 @@ async function getDocWorkerUrl(assignmentId: string|null): Promise<string|null>
export interface GristWSSettings {
// A factory function for creating the WebSocket so that we can use from node
// or browser.
makeWebSocket(url: string): WebSocket;
makeWebSocket(url: string): GristClientSocket;
// A function for getting the timezone, so the code can be used outside webpack -
// currently a timezone library is lazy loaded in a way that doesn't quite work
@ -74,7 +75,7 @@ export interface GristWSSettings {
export class GristWSSettingsBrowser implements GristWSSettings {
private _sessionStorage = getSessionStorage();
public makeWebSocket(url: string) { return new WebSocket(url); }
public makeWebSocket(url: string) { return new GristClientSocket(url); }
public getTimezone() { return guessTimezone(); }
public getPageUrl() { return G.window.location.href; }
public async getDocWorkerUrl(assignmentId: string|null) {
@ -125,7 +126,7 @@ export class GristWSConnection extends Disposable {
private _reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
private _reconnectAttempts: number = 0;
private _wantReconnect: boolean = true;
private _ws: WebSocket|null = null;
private _ws: GristClientSocket|null = null;
// The server sends incremental seqId numbers with each message on the connection, starting with
// 0. We keep track of them to allow for seamless reconnects.
@ -211,17 +212,16 @@ export class GristWSConnection extends Disposable {
}
/**
* @event serverMessage Triggered when a message arrives from the server. Callbacks receive
* the raw message data as an additional argument.
* Triggered when a message arrives from the server.
*/
public onmessage(ev: MessageEvent) {
public onmessage(data: string) {
if (!this._ws) {
// It's possible to receive a message after we disconnect, at least in tests (where
// WebSocket is a node library). Ignoring is easier than unsubscribing properly.
return;
}
this._scheduleHeartbeat();
this._processReceivedMessage(ev.data, true);
this._processReceivedMessage(data, true);
}
public send(message: any) {
@ -352,8 +352,8 @@ export class GristWSConnection extends Disposable {
this._ws.onmessage = this.onmessage.bind(this);
this._ws.onerror = (ev: Event|ErrorEvent) => {
this._log('GristWSConnection: onerror', 'error' in ev ? String(ev.error) : ev);
this._ws.onerror = (err: Error) => {
this._log('GristWSConnection: onerror', String(err));
};
this._ws.onclose = () => {

@ -22,7 +22,7 @@ import {fromCallback} from 'app/server/lib/serverUtils';
import {i18n} from 'i18next';
import * as crypto from 'crypto';
import moment from 'moment';
import * as WebSocket from 'ws';
import {GristServerSocket} from 'app/server/lib/GristServerSocket';
// How many messages and bytes to accumulate for a disconnected client before booting it.
// The benefit is that a client who temporarily disconnects and reconnects without missing much,
@ -97,8 +97,7 @@ export class Client {
private _missedMessagesTotalLength: number = 0;
private _destroyTimer: NodeJS.Timer|null = null;
private _destroyed: boolean = false;
private _websocket: WebSocket|null;
private _websocketEventHandlers: Array<{event: string, handler: (...args: any[]) => void}> = [];
private _websocket: GristServerSocket|null;
private _org: string|null = null;
private _profile: UserProfile|null = null;
private _user: FullUser|undefined = undefined;
@ -131,18 +130,14 @@ export class Client {
return this._locale;
}
public setConnection(websocket: WebSocket, counter: string|null, browserSettings: BrowserSettings) {
public setConnection(websocket: GristServerSocket, counter: string|null, browserSettings: BrowserSettings) {
this._websocket = websocket;
this._counter = counter;
this.browserSettings = browserSettings;
const addHandler = (event: string, handler: (...args: any[]) => void) => {
websocket.on(event, handler);
this._websocketEventHandlers.push({event, handler});
};
addHandler('error', (err: unknown) => this._onError(err));
addHandler('close', () => this._onClose());
addHandler('message', (msg: string) => this._onMessage(msg));
websocket.onerror = (err: Error) => this._onError(err);
websocket.onclose = () => this._onClose();
websocket.onmessage = (msg: string) => this._onMessage(msg);
}
/**
@ -189,7 +184,7 @@ export class Client {
public interruptConnection() {
if (this._websocket) {
this._removeWebsocketListeners();
this._websocket?.removeAllListeners();
this._websocket.terminate(); // close() is inadequate when ws routed via loadbalancer
this._websocket = null;
}
@ -359,7 +354,7 @@ export class Client {
// See also my report at https://stackoverflow.com/a/48411315/328565
await delay(250);
if (!this._destroyed && this._websocket?.readyState === WebSocket.OPEN) {
if (!this._destroyed && this._websocket?.isOpen) {
await this._sendToWebsocket(JSON.stringify({...clientConnectMsg, dup: true}));
}
} catch (err) {
@ -604,7 +599,7 @@ export class Client {
/**
* Processes an error on the websocket.
*/
private _onError(err: unknown) {
private _onError(err: Error) {
this._log.warn(null, "onError", err);
// TODO Make sure that this is followed by onClose when the connection is lost.
}
@ -613,7 +608,7 @@ export class Client {
* Processes the closing of a websocket.
*/
private _onClose() {
this._removeWebsocketListeners();
this._websocket?.removeAllListeners();
// Remove all references to the websocket.
this._websocket = null;
@ -629,15 +624,4 @@ export class Client {
this._destroyTimer = setTimeout(() => this.destroy(), Deps.clientRemovalTimeoutMs);
}
}
private _removeWebsocketListeners() {
if (this._websocket) {
// Avoiding websocket.removeAllListeners() because WebSocket.Server registers listeners
// internally for websockets it keeps track of, and we should not accidentally remove those.
for (const {event, handler} of this._websocketEventHandlers) {
this._websocket.off(event, handler);
}
this._websocketEventHandlers = [];
}
}
}

@ -35,7 +35,8 @@
import {EventEmitter} from 'events';
import * as http from 'http';
import * as https from 'https';
import * as WebSocket from 'ws';
import {GristSocketServer} from 'app/server/lib/GristSocketServer';
import {GristServerSocket} from 'app/server/lib/GristServerSocket';
import {parseFirstUrlPart} from 'app/common/gristUrls';
import {firstDefined, safeJsonParse} from 'app/common/gutil';
@ -50,6 +51,7 @@ import {localeFromRequest} from 'app/server/lib/ServerLocale';
import {fromCallback} from 'app/server/lib/serverUtils';
import {Sessions} from 'app/server/lib/Sessions';
import {i18n} from 'i18next';
import { trustOrigin } from './requestUtils';
export interface CommOptions {
sessions: Sessions; // A collection of all sessions for this instance of Grist
@ -74,7 +76,7 @@ export interface CommOptions {
export class Comm extends EventEmitter {
// Collection of all sessions; maps sessionIds to ScopedSession objects.
public readonly sessions: Sessions = this._options.sessions;
private _wss: WebSocket.Server[]|null = null;
private _wss: GristSocketServer[]|null = null;
private _clients = new Map<string, Client>(); // Maps clientIds to Client objects.
@ -146,11 +148,6 @@ export class Comm extends EventEmitter {
public async testServerShutdown() {
if (this._wss) {
for (const wssi of this._wss) {
// Terminate all clients. WebSocket.Server used to do it automatically in close() but no
// longer does (see https://github.com/websockets/ws/pull/1904#discussion_r668844565).
for (const ws of wssi.clients) {
ws.terminate();
}
await fromCallback((cb) => wssi.close(cb));
}
this._wss = null;
@ -204,7 +201,7 @@ export class Comm extends EventEmitter {
/**
* Processes a new websocket connection, and associates the websocket and a Client object.
*/
private async _onWebSocketConnection(websocket: WebSocket, req: http.IncomingMessage) {
private async _onWebSocketConnection(websocket: GristServerSocket, req: http.IncomingMessage) {
if (this._options.hosts) {
// DocWorker ID (/dw/) and version tag (/v/) may be present in this request but are not
// needed. addOrgInfo assumes req.url starts with /o/ if present.
@ -255,15 +252,18 @@ export class Comm extends EventEmitter {
if (this._options.httpsServer) { servers.push(this._options.httpsServer); }
const wss = [];
for (const server of servers) {
const wssi = new WebSocket.Server({server});
wssi.on('connection', async (websocket: WebSocket, req) => {
const wssi = new GristSocketServer(server, {
verifyClient: (req) => trustOrigin(req)
});
wssi.onconnection = async (websocket: GristServerSocket, req) => {
try {
await this._onWebSocketConnection(websocket, req);
} catch (e) {
log.error("Comm connection for %s threw exception: %s", req.url, e.message);
websocket.terminate(); // close() is inadequate when ws routed via loadbalancer
}
});
};
wss.push(wssi);
}
return wss;

@ -0,0 +1,136 @@
import * as WS from 'ws';
import * as EIO from 'engine.io';
export abstract class GristServerSocket {
public abstract set onerror(handler: (err: Error) => void);
public abstract set onclose(handler: () => void);
public abstract set onmessage(handler: (data: string) => void);
public abstract removeAllListeners(): void;
public abstract close(): void;
public abstract terminate(): void;
public abstract get isOpen(): boolean;
public abstract send(data: string, cb?: (err?: Error) => void): void;
}
export class GristServerSocketEIO extends GristServerSocket {
private _eventHandlers: Array<{ event: string, handler: (...args: any[]) => void }> = [];
private _messageCounter = 0;
// Engine.IO only invokes send() callbacks on success. We keep a map of
// send callbacks for messages in flight so that we can invoke them for
// any messages still unsent upon receiving a "close" event.
private _messageCallbacks: Map<number, (err: Error) => void> = new Map();
constructor(private _socket: EIO.Socket) { super(); }
public set onerror(handler: (err: Error) => void) {
// Note that as far as I can tell, Engine.IO sockets never emit "error"
// but instead include error information in the "close" event.
this._socket.on('error', handler);
this._eventHandlers.push({ event: 'error', handler });
}
public set onclose(handler: () => void) {
const wrappedHandler = (reason: string, description: any) => {
// In practice, when available, description has more error details,
// possibly in the form of an Error object.
const maybeErr = description ?? reason;
const err = maybeErr instanceof Error ? maybeErr : new Error(maybeErr);
for (const cb of this._messageCallbacks.values()) {
cb(err);
}
this._messageCallbacks.clear();
handler();
};
this._socket.on('close', wrappedHandler);
this._eventHandlers.push({ event: 'close', handler: wrappedHandler });
}
public set onmessage(handler: (data: string) => void) {
const wrappedHandler = (msg: Buffer) => {
handler(msg.toString());
};
this._socket.on('message', wrappedHandler);
this._eventHandlers.push({ event: 'message', handler: wrappedHandler });
}
public removeAllListeners() {
for (const { event, handler } of this._eventHandlers) {
this._socket.off(event, handler);
}
this._eventHandlers = [];
}
public close() {
this._socket.close();
}
// Terminates the connection without waiting for the client to close its own side.
public terminate() {
// Trigger a normal close. For the polling transport, this is sufficient and instantaneous.
this._socket.close(/* discard */ true);
}
public get isOpen() {
return this._socket.readyState === 'open';
}
public send(data: string, cb?: (err?: Error) => void) {
const msgNum = this._messageCounter++;
if (cb) {
this._messageCallbacks.set(msgNum, cb);
}
this._socket.send(data, {}, () => {
if (cb && this._messageCallbacks.delete(msgNum)) {
cb();
}
});
}
}
export class GristServerSocketWS extends GristServerSocket {
private _eventHandlers: Array<{ event: string, handler: (...args: any[]) => void }> = [];
constructor(private _ws: WS.WebSocket) { super(); }
public set onerror(handler: (err: Error) => void) {
this._ws.on('error', handler);
this._eventHandlers.push({ event: 'error', handler });
}
public set onclose(handler: () => void) {
this._ws.on('close', handler);
this._eventHandlers.push({ event: 'close', handler });
}
public set onmessage(handler: (data: string) => void) {
this._ws.on('message', (msg: Buffer) => handler(msg.toString()));
this._eventHandlers.push({ event: 'message', handler });
}
public removeAllListeners() {
// Avoiding websocket.removeAllListeners() because WS.Server registers listeners
// internally for websockets it keeps track of, and we should not accidentally remove those.
for (const { event, handler } of this._eventHandlers) {
this._ws.off(event, handler);
}
this._eventHandlers = [];
}
public close() {
this._ws.close();
}
public terminate() {
this._ws.terminate();
}
public get isOpen() {
return this._ws.readyState === WS.OPEN;
}
public send(data: string, cb?: (err?: Error) => void) {
this._ws.send(data, cb);
}
}

@ -0,0 +1,111 @@
import * as http from 'http';
import * as WS from 'ws';
import * as EIO from 'engine.io';
import {GristServerSocket, GristServerSocketEIO, GristServerSocketWS} from './GristServerSocket';
import * as net from 'net';
const MAX_PAYLOAD = 100e6;
export interface GristSocketServerOptions {
verifyClient?: (request: http.IncomingMessage) => boolean;
}
export class GristSocketServer {
private _wsServer: WS.Server;
private _eioServer: EIO.Server;
private _connectionHandler: (socket: GristServerSocket, req: http.IncomingMessage) => void;
constructor(server: http.Server, private _options?: GristSocketServerOptions) {
this._wsServer = new WS.Server({ noServer: true, maxPayload: MAX_PAYLOAD });
this._eioServer = new EIO.Server({
// We only use Engine.IO for its polling transport,
// so we disable the built-in Engine.IO upgrade mechanism.
allowUpgrades: false,
transports: ['polling'],
maxHttpBufferSize: MAX_PAYLOAD,
cors: {
// This will cause Engine.IO to reflect any client-provided Origin into
// the Access-Control-Allow-Origin header, essentially disabling the
// protection offered by the Same-Origin Policy. This sounds insecure
// but is actually the security model of native WebSockets (they are
// not covered by SOP; any webpage can open a WebSocket connecting to
// any other domain, including the target domain's cookies; it is up to
// the receiving server to check the request's Origin header). Since
// the connection attempt is validated in `verifyClient` later,
// it is safe to let any client attempt a connection here.
origin: true,
// We need to allow the client to send its cookies. See above for the
// reasoning on why it is safe to do so.
credentials: true,
methods: ["GET", "POST"],
},
});
this._eioServer.on('connection', this._onEIOConnection.bind(this));
this._attach(server);
}
public set onconnection(handler: (socket: GristServerSocket, req: http.IncomingMessage) => void) {
this._connectionHandler = handler;
}
public close(cb: (...args: any[]) => void) {
this._eioServer.close();
// Terminate all clients. WS.Server used to do it automatically in close() but no
// longer does (see https://github.com/websockets/ws/pull/1904#discussion_r668844565).
for (const ws of this._wsServer.clients) {
ws.terminate();
}
this._wsServer.close(cb);
}
private _attach(server: http.Server) {
// Forward all WebSocket upgrade requests to WS
server.on('upgrade', (request, socket, head) => {
if (this._options?.verifyClient && !this._options.verifyClient(request)) {
// Because we are handling an "upgrade" event, we don't have access to
// a "response" object, just the raw socket. We can still construct
// a well-formed HTTP error response.
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
}
this._wsServer.handleUpgrade(request, socket as net.Socket, head, (client) => {
this._connectionHandler?.(new GristServerSocketWS(client), request);
});
});
// At this point an Express app is installed as the handler for the server's
// "request" event. We need to install our own listener instead, to intercept
// requests that are meant for the Engine.IO polling implementation.
const listeners = [...server.listeners("request")];
server.removeAllListeners("request");
server.on("request", (req, res) => {
// Intercept requests that have transport=polling in their querystring
if (/[&?]transport=polling(&|$)/.test(req.url ?? '')) {
if (this._options?.verifyClient && !this._options.verifyClient(req)) {
res.writeHead(403).end();
return;
}
this._eioServer.handleRequest(req, res);
} else {
// Otherwise fallback to the pre-existing listener(s)
for (const listener of listeners) {
listener.call(server, req, res);
}
}
});
server.on("close", this.close.bind(this));
}
private _onEIOConnection(socket: EIO.Socket) {
const req = socket.request;
(socket as any).request = null; // Free initial request as recommended in the Engine.IO documentation
this._connectionHandler?.(new GristServerSocketEIO(socket), req);
}
}

@ -139,6 +139,8 @@
"diff-match-patch": "1.0.5",
"dompurify": "3.0.6",
"double-ended-queue": "2.1.0-0",
"engine.io": "^6.5.4",
"engine.io-client": "^6.5.3",
"exceljs": "4.2.1",
"express": "4.18.2",
"file-type": "16.5.4",

@ -4,11 +4,11 @@ import {assert} from 'chai';
import * as http from 'http';
import {AddressInfo} from 'net';
import * as sinon from 'sinon';
import WebSocket from 'ws';
import * as path from 'path';
import * as tmp from 'tmp';
import {GristWSConnection, GristWSSettings} from 'app/client/components/GristWSConnection';
import {GristClientSocket, GristClientSocketOptions} from 'app/client/components/GristClientSocket';
import {Comm as ClientComm} from 'app/client/components/Comm';
import * as log from 'app/client/lib/log';
import {Comm} from 'app/server/lib/Comm';
@ -58,7 +58,11 @@ describe('Comm', function() {
async function stopComm() {
comm?.destroyAllClients();
return fromCallback(cb => server.close(cb));
await comm?.testServerShutdown();
await fromCallback(cb => {
server.close(cb);
server.closeAllConnections();
});
}
const assortedMethods: {[name: string]: ClientMethod} = {
@ -95,34 +99,43 @@ describe('Comm', function() {
sandbox.restore();
});
function getMessages(ws: WebSocket, count: number): Promise<any[]> {
function getMessages(ws: GristClientSocket, count: number): Promise<any[]> {
return new Promise((resolve, reject) => {
const messages: object[] = [];
ws.on('error', reject);
ws.on('message', (msg: string) => {
messages.push(JSON.parse(msg));
ws.onerror = (err) => {
ws.onmessage = null;
reject(err);
};
ws.onmessage = (data: string) => {
messages.push(JSON.parse(data));
if (messages.length >= count) {
ws.onerror = null;
ws.onmessage = null;
resolve(messages);
ws.removeListener('error', reject);
ws.removeAllListeners('message');
}
});
};
});
}
/**
* Returns a promise for the connected websocket.
*/
function connect() {
const ws = new WebSocket('ws://localhost:' + (server.address() as AddressInfo).port);
return new Promise<WebSocket>((resolve, reject) => {
ws.on('open', () => resolve(ws));
ws.on('error', reject);
function connect(options?: GristClientSocketOptions): Promise<GristClientSocket> {
const ws = new GristClientSocket('ws://localhost:' + (server.address() as AddressInfo).port, options);
return new Promise<GristClientSocket>((resolve, reject) => {
ws.onopen = () => {
ws.onerror = null;
resolve(ws);
};
ws.onerror = (err) => {
ws.onopen = null;
reject(err);
};
});
}
describe("server methods", function() {
let ws: WebSocket;
let ws: GristClientSocket;
beforeEach(async function() {
await startComm(assortedMethods);
ws = await connect();
@ -370,7 +383,8 @@ describe('Comm', function() {
// Intercept the call to _onClose to know when it occurs, since we are trying to hit a
// situation where 'close' and 'failedSend' events happen in either order.
const stubOnClose = sandbox.stub(Client.prototype as any, '_onClose')
.callsFake(function(this: Client) {
.callsFake(async function(this: Client) {
if (!options.closeHappensFirst) { await delay(10); }
eventsSeen.push('close');
return (stubOnClose as any).wrappedMethod.apply(this, arguments);
});
@ -462,6 +476,9 @@ describe('Comm', function() {
if (options.useSmallMsgs) {
assert.deepEqual(eventsSeen, ['close']);
} else {
// Make sure to have waited long enough for the 'close' event we may have delayed
await delay(20);
// Large messages now cause a send to fail, after filling up buffer, and close the socket.
assert.deepEqual(eventsSeen, ['close', 'close']);
}
@ -490,6 +507,34 @@ describe('Comm', function() {
assert.deepEqual(eventSpy.getCalls().map(call => call.args[0].n), [n - 1]);
}
});
describe("Allowed Origin", function() {
beforeEach(async function () {
await startComm(assortedMethods);
});
afterEach(async function() {
await stopComm();
});
it('should allow only example.com', async () => {
async function checkOrigin(headers: { origin: string, host: string }, allowed: boolean) {
const promise = connect({ headers });
if (allowed) {
await assert.isFulfilled(promise, `${headers.host} should allow ${headers.origin}`);
} else {
await assert.isRejected(promise, /.*/, `${headers.host} should reject ${headers.origin}`);
}
}
await checkOrigin({origin: "https://www.toto.com", host: "worker.example.com"}, false);
await checkOrigin({origin: "https://badexample.com", host: "worker.example.com"}, false);
await checkOrigin({origin: "https://bad.com/example.com", host: "worker.example.com"}, false);
await checkOrigin({origin: "https://front.example.com", host: "worker.example.com"}, true);
await checkOrigin({origin: "https://front.example.com:3000", host: "worker.example.com"}, true);
await checkOrigin({origin: "https://example.com", host: "example.com"}, true);
});
});
});
// Waits for condFunc() to return true, for up to timeoutMs milliseconds, sleeping for stepMs
@ -513,7 +558,7 @@ function getWSSettings(docWorkerUrl: string): GristWSSettings {
let clientId: string = 'clientid-abc';
let counter: number = 0;
return {
makeWebSocket(url: string): any { return new WebSocket(url, undefined, {}); },
makeWebSocket(url: string): any { return new GristClientSocket(url); },
async getTimezone() { return 'UTC'; },
getPageUrl() { return "http://localhost"; },
async getDocWorkerUrl() { return docWorkerUrl; },

@ -4,7 +4,7 @@ import { SchemaTypes } from 'app/common/schema';
import { FlexServer } from 'app/server/lib/FlexServer';
import axios from 'axios';
import pick = require('lodash/pick');
import WebSocket from 'ws';
import {GristClientSocket} from 'app/client/components/GristClientSocket';
interface GristRequest {
reqId: number;
@ -34,9 +34,9 @@ export class GristClient {
private _consumer: () => void;
private _ignoreTrivialActions: boolean = false;
constructor(public ws: any) {
ws.onmessage = (data: any) => {
const msg = pick(JSON.parse(data.data),
constructor(public ws: GristClientSocket) {
ws.onmessage = (data: string) => {
const msg = pick(JSON.parse(data),
['reqId', 'error', 'errorCode', 'data', 'type', 'docFD']);
if (this._ignoreTrivialActions && msg.type === 'docUserAction' &&
msg.data?.actionGroup?.internal === true &&
@ -149,7 +149,6 @@ export class GristClient {
}
public async close() {
this.ws.terminate();
this.ws.close();
}
@ -180,17 +179,19 @@ export async function openClient(server: FlexServer, email: string, org: string,
} else {
headers[emailHeader] = email;
}
const ws = new WebSocket('ws://localhost:' + server.getOwnPort() + `/o/${org}`, {
const ws = new GristClientSocket('ws://localhost:' + server.getOwnPort() + `/o/${org}`, {
headers
});
const client = new GristClient(ws);
await new Promise(function(resolve, reject) {
ws.on('open', function() {
ws.onopen = function() {
ws.onerror = null;
resolve(ws);
});
ws.on('error', function(err: any) {
};
ws.onerror = function(err: Error) {
ws.onopen = null;
reject(err);
});
};
});
return client;
}

@ -0,0 +1,158 @@
import { assert } from 'chai';
import * as http from 'http';
import { GristClientSocket } from 'app/client/components/GristClientSocket';
import { GristSocketServer } from 'app/server/lib/GristSocketServer';
import { fromCallback, listenPromise } from 'app/server/lib/serverUtils';
import { AddressInfo } from 'net';
import httpProxy from 'http-proxy';
describe(`GristSockets`, function () {
beforeEach(async function () {
await startSocketServer();
});
afterEach(async function () {
await stopSocketServer();
});
let server: http.Server | null;
let serverPort: number;
let wsAddress: string;
let socketServer: GristSocketServer | null;
async function startSocketServer() {
server = http.createServer((req, res) => res.writeHead(404).end());
socketServer = new GristSocketServer(server);
await listenPromise(server.listen(0, 'localhost'));
serverPort = (server.address() as AddressInfo).port;
wsAddress = 'ws://localhost:' + serverPort;
}
async function stopSocketServer() {
//await delay(90_000);
await fromCallback(cb => socketServer?.close(cb));
await fromCallback(cb => { server?.close(); server?.closeAllConnections(); server?.on("close", cb); });
socketServer = server = null;
}
function getMessages(ws: GristClientSocket, count: number): Promise<string[]> {
return new Promise((resolve, reject) => {
const messages: string[] = [];
ws.onerror = (err) => {
ws.onerror = ws.onmessage = null;
reject(err);
};
ws.onmessage = (data: string) => {
messages.push(data);
if (messages.length >= count) {
ws.onerror = ws.onmessage = null;
resolve(messages);
}
};
});
}
/**
* Returns a promise for the connected websocket.
*/
function connectClient(url: string): Promise<GristClientSocket> {
const ws = new GristClientSocket(url);
return new Promise<GristClientSocket>((resolve, reject) => {
ws.onopen = () => {
ws.onerror = null;
resolve(ws);
};
ws.onerror = (err) => {
ws.onopen = null;
reject(err);
};
});
}
it("should expose initial request", async function () {
const connectionPromise = new Promise<http.IncomingMessage>((resolve) => {
socketServer!.onconnection = (socket, req) => {
resolve(req);
};
});
new GristClientSocket(wsAddress + "/path?query=value", {
headers: { "cookie": "session=1234" }
});
const req = await connectionPromise;
// Engine.IO may append extra query parameters, so we check only the start of the URL
assert.match(req.url!, /^\/path\?query=value/);
assert.equal(req.headers.cookie, "session=1234");
});
it("should receive and send messages", async function () {
socketServer!.onconnection = (socket, req) => {
socket.onmessage = (data) => {
socket.send("hello, " + data);
socket.close();
};
};
const clientWs = await connectClient(wsAddress);
clientWs.send("world");
assert.deepEqual(await getMessages(clientWs, 1), ["hello, world"]);
});
it("should invoke send callbacks", async function () {
const connectionPromise = new Promise<void>((resolve) => {
socketServer!.onconnection = (socket, req) => {
socket.send("hello", () => resolve());
};
});
await connectClient(wsAddress);
await connectionPromise;
});
let proxy: httpProxy | null;
let proxyServer: http.Server | null;
let proxyPort: number;
// Start an HTTP proxy that does not support WebSockets
async function startProxyServer() {
proxy = httpProxy.createProxy({
target: `http://localhost:${serverPort}`,
ws: false,
timeout: 1000,
});
proxy.on('error', () => { });
proxyServer = http.createServer(proxy.web.bind(proxy));
proxyServer.on('upgrade', (req, socket) => socket.destroy());
await listenPromise(proxyServer.listen(0, 'localhost'));
proxyPort = (proxyServer.address() as AddressInfo).port;
}
async function stopProxyServer() {
proxy?.close();
await fromCallback(cb => { proxyServer?.close(cb); proxyServer?.closeAllConnections(); });
proxyServer = proxy = null;
}
beforeEach(async function () {
await startProxyServer();
});
afterEach(async function () {
await stopProxyServer();
});
describe("GristClientSocket", function () {
it("can fall back to polling", async function () {
socketServer!.onconnection = (socket, req) => {
socket.onmessage = (data) => {
socket.send("hello, " + data);
};
};
const clientWs = await connectClient(`ws://localhost:${proxyPort}`);
clientWs.send("world");
assert.deepEqual(await getMessages(clientWs, 1), ["hello, world"]);
clientWs.close();
});
});
});

@ -12,7 +12,7 @@ import {createTestDir, EnvironmentSnapshot, setTmpLogLevel} from 'test/server/te
import {assert} from 'chai';
import * as cookie from 'cookie';
import fetch from 'node-fetch';
import WebSocket from 'ws';
import {GristClientSocket} from 'app/client/components/GristClientSocket';
describe('ManyFetches', function() {
this.timeout(30000);
@ -244,7 +244,7 @@ describe('ManyFetches', function() {
return function createConnectionFunc() {
let clientId: string = '0';
return GristWSConnection.create(null, {
makeWebSocket(url: string): any { return new WebSocket(url, undefined, { headers }); },
makeWebSocket(url: string) { return new GristClientSocket(url, { headers }); },
getTimezone() { return Promise.resolve('UTC'); },
getPageUrl() { return pageUrl; },
getDocWorkerUrl() { return Promise.resolve(docWorkerUrl); },

@ -544,6 +544,11 @@
resolved "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz"
integrity sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==
"@socket.io/component-emitter@~3.1.0":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz#96116f2a912e0c02817345b3c10751069920d553"
integrity sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==
"@sqltools/formatter@^1.2.2":
version "1.2.3"
resolved "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.3.tgz"
@ -626,6 +631,18 @@
resolved "https://registry.npmjs.org/@types/content-disposition/-/content-disposition-0.5.2.tgz"
integrity sha1-GMyw/RJoCOSYqCDLgUnAByorkGY=
"@types/cookie@^0.4.1":
version "0.4.1"
resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.4.1.tgz#bfd02c1f2224567676c1545199f87c3a861d878d"
integrity sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==
"@types/cors@^2.8.12":
version "2.8.17"
resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.17.tgz#5d718a5e494a8166f569d986794e49c48b216b2b"
integrity sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==
dependencies:
"@types/node" "*"
"@types/d3@^3":
version "3.5.44"
resolved "https://registry.npmjs.org/@types/d3/-/d3-3.5.44.tgz"
@ -844,6 +861,13 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.9.tgz#02d013de7058cea16d36168ef2fc653464cfbad4"
integrity sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg==
"@types/node@>=10.0.0":
version "20.11.16"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.16.tgz#4411f79411514eb8e2926f036c86c9f0e4ec6708"
integrity sha512-gKb0enTmRCzXSSUJDq6/sPcqrfCv2mkkG6Jt/clpn5eiCbKTY+SgZUxo+p8ZKMof5dCp9vHQUAB7wOUTod22wQ==
dependencies:
undici-types "~5.26.4"
"@types/node@^14.0.1":
version "14.17.6"
resolved "https://registry.npmjs.org/@types/node/-/node-14.17.6.tgz"
@ -1253,7 +1277,7 @@ accept-language-parser@1.5.0:
resolved "https://registry.npmjs.org/accept-language-parser/-/accept-language-parser-1.5.0.tgz"
integrity sha1-iHfFQECo3LWeCgfZwf3kIpgzR5E=
accepts@~1.3.8:
accepts@~1.3.4, accepts@~1.3.8:
version "1.3.8"
resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e"
integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==
@ -1667,6 +1691,11 @@ base64-js@^1.2.0, base64-js@^1.3.0, base64-js@^1.3.1:
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
base64id@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/base64id/-/base64id-2.0.0.tgz#2770ac6bc47d312af97a8bf9a634342e0cd25cb6"
integrity sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==
basic-auth@~2.0.0:
version "2.0.1"
resolved "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz"
@ -2573,11 +2602,24 @@ cookie@0.5.0:
resolved "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz"
integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==
cookie@~0.4.1:
version "0.4.2"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432"
integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==
core-util-is@~1.0.0:
version "1.0.3"
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85"
integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==
cors@~2.8.5:
version "2.8.5"
resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29"
integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==
dependencies:
object-assign "^4"
vary "^1"
crc-32@^1.2.0:
version "1.2.0"
resolved "https://registry.npmjs.org/crc-32/-/crc-32-1.2.0.tgz"
@ -2737,7 +2779,7 @@ debug@4:
dependencies:
ms "2.1.2"
debug@4.3.4, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4:
debug@4.3.4, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4, debug@~4.3.1:
version "4.3.4"
resolved "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz"
integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
@ -3072,6 +3114,38 @@ end-of-stream@^1.0.0, end-of-stream@^1.1.0, end-of-stream@^1.4.1:
dependencies:
once "^1.4.0"
engine.io-client@^6.5.3:
version "6.5.3"
resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-6.5.3.tgz#4cf6fa24845029b238f83c628916d9149c399bc5"
integrity sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q==
dependencies:
"@socket.io/component-emitter" "~3.1.0"
debug "~4.3.1"
engine.io-parser "~5.2.1"
ws "~8.11.0"
xmlhttprequest-ssl "~2.0.0"
engine.io-parser@~5.2.1:
version "5.2.2"
resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.2.2.tgz#37b48e2d23116919a3453738c5720455e64e1c49"
integrity sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw==
engine.io@^6.5.4:
version "6.5.4"
resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-6.5.4.tgz#6822debf324e781add2254e912f8568508850cdc"
integrity sha512-KdVSDKhVKyOi+r5uEabrDLZw2qXStVvCsEB/LN3mw4WFi6Gx50jTyuxYVCwAAC0U46FdnzP/ScKRBTXb/NiEOg==
dependencies:
"@types/cookie" "^0.4.1"
"@types/cors" "^2.8.12"
"@types/node" ">=10.0.0"
accepts "~1.3.4"
base64id "2.0.0"
cookie "~0.4.1"
cors "~2.8.5"
debug "~4.3.1"
engine.io-parser "~5.2.1"
ws "~8.11.0"
enhanced-resolve@^5.9.3:
version "5.9.3"
resolved "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.9.3.tgz"
@ -5894,7 +5968,7 @@ nwsapi@^2.2.7:
resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.7.tgz#738e0707d3128cb750dddcfe90e4610482df0f30"
integrity sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ==
object-assign@^4.0.1, object-assign@^4.1.1:
object-assign@^4, object-assign@^4.0.1, object-assign@^4.1.1:
version "4.1.1"
resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz"
integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
@ -7863,6 +7937,11 @@ underscore@1.12.1, underscore@>=1.8.3, underscore@^1.8.0:
resolved "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz"
integrity sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==
undici-types@~5.26.4:
version "5.26.5"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617"
integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==
unique-filename@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-1.1.1.tgz#1d69769369ada0583103a1e6ae87681b56573230"
@ -8053,7 +8132,7 @@ value-or-function@^3.0.0:
resolved "https://registry.yarnpkg.com/value-or-function/-/value-or-function-3.0.0.tgz#1c243a50b595c1be54a754bfece8563b9ff8d813"
integrity sha512-jdBB2FrWvQC/pnPtIqcLsMaQgjhdb6B7tk1MMyTKapox+tQZbdRP4uLxu/JY0t7fbfDCUMnuelzEYv5GsxHhdg==
vary@~1.1.2:
vary@^1, vary@~1.1.2:
version "1.1.2"
resolved "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz"
integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=
@ -8369,6 +8448,11 @@ ws@^8.14.2:
resolved "https://registry.yarnpkg.com/ws/-/ws-8.15.0.tgz#db080a279260c5f532fc668d461b8346efdfcf86"
integrity sha512-H/Z3H55mrcrgjFwI+5jKavgXvwQLtfPCUEp6pi35VhoB0pfcHnSoyuTzkBEZpzq49g1193CUEwIvmsjcotenYw==
ws@~8.11.0:
version "8.11.0"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.11.0.tgz#6a0d36b8edfd9f96d8b25683db2f8d7de6e8e143"
integrity sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==
xdg-basedir@^4.0.0:
version "4.0.0"
resolved "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz"
@ -8438,6 +8522,11 @@ xmldom@^0.1.0, xmldom@~0.1.15:
resolved "https://registry.npmjs.org/xmldom/-/xmldom-0.1.31.tgz"
integrity sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ==
xmlhttprequest-ssl@~2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz#91360c86b914e67f44dce769180027c0da618c67"
integrity sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==
xpath.js@>=0.0.3:
version "1.1.0"
resolved "https://registry.npmjs.org/xpath.js/-/xpath.js-1.1.0.tgz"

Loading…
Cancel
Save