gristlabs_grist-core/test/server/gristClient.ts
Jonathan Perret 96b652fb52
Support HTTP long polling as an alternative to WebSockets (#859)
The motivation for supporting an alternative to WebSockets is that while all browsers supported by Grist offer native WebSocket support, some networking environments do not allow WebSocket traffic.

Engine.IO is used as the underlying implementation of HTTP long polling. The Grist client will first attempt a regular WebSocket connection, using the same protocol and endpoints as before, but fall back to long polling using Engine.IO if the WebSocket connection fails.

Include these changes:
- CORS websocket requests are now rejected as a stronger security measure. This shouldn’t affect anything in practice; but previously it could be possible to make unauthenticated websocket requests from another origin.
- GRIST_HOST variable no longer affects CORS responses (also should not affect anything in practice, as it wasn't serving a useful purpose)
2024-03-28 13:22:20 -04:00

198 lines
5.7 KiB
TypeScript

import { DocAction } from 'app/common/DocActions';
import { DocData } from 'app/common/DocData';
import { SchemaTypes } from 'app/common/schema';
import { FlexServer } from 'app/server/lib/FlexServer';
import axios from 'axios';
import pick = require('lodash/pick');
import {GristClientSocket} from 'app/client/components/GristClientSocket';
interface GristRequest {
reqId: number;
method: string;
args: any[];
}
interface GristResponse {
reqId: number;
error?: string;
errorCode?: string;
data?: any;
}
interface GristMessage {
type: 'clientConnect' | 'docUserAction';
docFD: number;
data: any;
}
export class GristClient {
public messages: GristMessage[] = [];
private _requestId: number = 0;
private _pending: Array<GristResponse|GristMessage> = [];
private _docData?: DocData; // accumulate tabular info like a real client.
private _consumer: () => void;
private _ignoreTrivialActions: boolean = false;
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 &&
msg.data?.docActions?.length === 0) {
return;
}
this._pending.push(msg);
if (msg.data?.doc) {
this._docData = new DocData(() => {
throw new Error('no fetches');
}, msg.data.doc);
}
if (this._docData && msg.type === 'docUserAction') {
const docActions = msg.data?.docActions || [];
for (const docAction of docActions) {
this._docData.receiveAction(docAction);
}
}
if (this._consumer) { this._consumer(); }
};
}
// After a document is opened, the sandbox recomputes its formulas and sends any changes.
// The client will receive an update even if there are no changes. This may be useful in
// the future to know that the document is up to date. But for testing, this asynchronous
// message can be awkward. Call this method to ignore it.
public ignoreTrivialActions() {
this._ignoreTrivialActions = true;
}
public flush() {
this._pending = [];
}
public shift() {
return this._pending.shift();
}
public count() {
return this._pending.length;
}
public get docData() {
if (!this._docData) { throw new Error('no DocData'); }
return this._docData;
}
public getMetaRecords(tableId: keyof SchemaTypes) {
return this.docData.getMetaTable(tableId).getRecords();
}
public async read(): Promise<any> {
for (;;) {
if (this._pending.length) {
return this._pending.shift();
}
await new Promise<void>(resolve => this._consumer = resolve);
}
}
public async readMessage(): Promise<GristMessage> {
const result = await this.read();
if (!result.type) {
throw new Error(`message looks wrong: ${JSON.stringify(result)}`);
}
return result;
}
public async readResponse(): Promise<GristResponse> {
this.messages = [];
for (;;) {
const result = await this.read();
if (result.reqId === undefined) {
this.messages.push(result);
continue;
}
if (result.reqId !== this._requestId) {
throw new Error("unexpected request id");
}
return result;
}
}
public waitForServer() {
// send an arbitrary failing message and wait for response.
return this.send('ping');
}
// Helper to read the next docUserAction ignoring anything else (e.g. a duplicate clientConnect).
public async readDocUserAction(): Promise<DocAction[]> {
while (true) { // eslint-disable-line no-constant-condition
const msg = await this.readMessage();
if (msg.type === 'docUserAction') {
return msg.data.docActions;
}
}
}
public async send(method: string, ...args: any[]): Promise<GristResponse> {
const p = this.readResponse();
this._requestId++;
const req: GristRequest = {
reqId: this._requestId,
method,
args
};
this.ws.send(JSON.stringify(req));
const result = await p;
return result;
}
public async close() {
this.ws.close();
}
public async openDocOnConnect(docId: string) {
const msg = await this.readMessage();
if (msg.type !== 'clientConnect') { throw new Error('expected clientConnect'); }
const openDoc = await this.send('openDoc', docId);
if (openDoc.error) { throw new Error('error in openDocOnConnect'); }
return openDoc;
}
}
export async function openClient(server: FlexServer, email: string, org: string,
emailHeader?: string): Promise<GristClient> {
const headers: Record<string, string> = {};
if (!emailHeader) {
const resp = await axios.get(`${server.getOwnUrl()}/test/session`);
const cookie = resp.headers['set-cookie'][0];
if (email !== 'anon@getgrist.com') {
const cid = decodeURIComponent(cookie.split('=')[1].split(';')[0]);
const comm = server.getComm();
const sessionId = comm.getSessionIdFromCookie(cid)!;
const scopedSession = comm.getOrCreateSession(sessionId, {org});
const profile = { email, email_verified: true, name: "Someone" };
await scopedSession.updateUserProfile({} as any, profile);
}
headers.Cookie = cookie;
} else {
headers[emailHeader] = email;
}
const ws = new GristClientSocket('ws://localhost:' + server.getOwnPort() + `/o/${org}`, {
headers
});
const client = new GristClient(ws);
await new Promise(function(resolve, reject) {
ws.onopen = function() {
ws.onerror = null;
resolve(ws);
};
ws.onerror = function(err: Error) {
ws.onopen = null;
reject(err);
};
});
return client;
}