helene
Version:
Real-time Web Apps for Node.js
415 lines • 15.4 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.Client = void 0;
const utils_1 = require("../utils");
const ejson_1 = require("../ejson");
const isEmpty_1 = __importDefault(require("lodash/isEmpty"));
const isFunction_1 = __importDefault(require("lodash/isFunction"));
const isObject_1 = __importDefault(require("lodash/isObject"));
const isPlainObject_1 = __importDefault(require("lodash/isPlainObject"));
const isString_1 = __importDefault(require("lodash/isString"));
const last_1 = __importDefault(require("lodash/last"));
const merge_1 = __importDefault(require("lodash/merge"));
const pick_1 = __importDefault(require("lodash/pick"));
const query_string_1 = __importDefault(require("query-string"));
const call_method_proxy_1 = require("./call-method-proxy");
const client_channel_1 = require("./client-channel");
const client_http_1 = require("./client-http");
const client_socket_1 = require("./client-socket");
const idle_timeout_1 = require("./idle-timeout");
/**
* When working with Next.js, it is probably a good idea to not run this in the
* server side by using it inside a `useEffect` hook.
*/
class Client extends client_channel_1.ClientChannel {
uuid;
queue;
clientSocket;
clientHttp;
context = {};
errorHandler;
channels = new Map();
timeouts = new Set();
initialized = false;
authenticated = false;
options = {
host: 'localhost',
secure: false,
errorHandler: null,
debug: false,
allowedContextKeys: [],
meta: {},
};
initializing;
idleTimeout = null;
m;
static ENABLE_HEARTBEAT = true;
constructor(options = {}) {
super(utils_1.NO_CHANNEL);
this.m = (0, call_method_proxy_1.callMethodProxy)(this);
this.uuid = utils_1.Presentation.uuid();
this.setClient(this);
this.options = (0, merge_1.default)(this.options, options);
this.clientHttp = new client_http_1.ClientHttp(this);
this.queue = new utils_1.PromiseQueue();
this.channels.set(utils_1.NO_CHANNEL, this);
/**
* The client should only ever be ready when the context is loaded,
* scheduling the client socket construction for after the context
* is first loaded does the trick as the init event is only emitted after
* the ClientSocket is built.
*/
this.loadContext();
this.authenticated = !!this.context.token;
this.clientSocket = new client_socket_1.ClientSocket(this, this.options.ws);
this.on(utils_1.ClientEvents.ERROR, console.error);
if (utils_1.Environment.isBrowser) {
if (utils_1.Environment.isDevelopment) {
// @ts-ignore
window.Helene = this;
}
this.attachDevTools().then(() => {
this.debugger('DevTools attached');
});
}
this.connect().catch(console.error);
this.idleTimeout = new idle_timeout_1.IdleTimeout(this);
}
get isConnecting() {
return !!this.clientSocket?.connecting;
}
get isOffline() {
return !this.clientSocket?.ready;
}
get isOnline() {
return !!this.clientSocket?.ready;
}
get connected() {
return this.clientSocket.ready;
}
async connect() {
this.clientSocket.connect();
try {
await this.waitFor(utils_1.ClientEvents.INITIALIZED, 10000);
}
catch {
console.error('Helene: Initialization timeout');
console.trace();
}
}
debugger(...args) {
if (this.options.debug)
console.debug(...args);
}
loadContext() {
if (typeof localStorage === 'undefined')
return;
const context = localStorage.getItem('context');
if (!context)
return;
this.updateContext(ejson_1.EJSON.parse(context));
}
setContext(context) {
this.context = context;
if (typeof localStorage !== 'undefined') {
localStorage.setItem('context', ejson_1.EJSON.stringify(context));
}
this.emit(utils_1.ClientEvents.CONTEXT_CHANGED);
}
async setContextAndReInit(context) {
this.setContext(context);
await this.initialize();
}
updateContext(context) {
const newContext = (0, merge_1.default)({}, this.context, context);
this.setContext(newContext);
}
clearContext() {
this.context = {};
if (typeof localStorage !== 'undefined') {
localStorage.removeItem('context');
}
this.emit(utils_1.ClientEvents.CONTEXT_CHANGED);
}
async close() {
this.emit(utils_1.ClientEvents.CLOSE);
this.timeouts.forEach(timeout => clearTimeout(timeout));
// Clear event sub/unsub timeouts.
this.channels.forEach(channel => {
channel.emit(utils_1.HeleneEvents.COMMIT_PENDING_SUBSCRIPTIONS, {});
});
this.channels.forEach(channel => {
channel.emit(utils_1.HeleneEvents.COMMIT_PENDING_UNSUBSCRIPTIONS, {});
});
await this.clientSocket.close();
}
#callInit(token, context = {}) {
return this.call(utils_1.Methods.RPC_INIT, {
token,
meta: this.options.meta,
...context,
});
}
/**
* Initializes the client. It should be called before any other method.
*
* It should be called whenever a transport is connected, either on reconnection or from calling `connect`.
*/
async initialize() {
if (this.initializing) {
console.log('Helene: Already initializing');
await this.waitFor(utils_1.ClientEvents.INITIALIZED);
return;
}
this.initializing = true;
this.initialized = false;
this.loadContext();
this.emit(utils_1.ClientEvents.INITIALIZING);
const { token } = this.context ?? {};
if (token && !(0, isString_1.default)(token))
throw new Error(utils_1.Errors.INVALID_TOKEN);
const context = this.options.allowedContextKeys.length
? (0, pick_1.default)(this.context ?? {}, this.options.allowedContextKeys)
: {};
let result = await this.#callInit(token, context);
if (token && !result) {
console.log('Helene: Initialization failed, trying again');
result = await this.#callInit(token, context);
}
/**
* It needs to validate the token first as it can be invalid
*/
this.authenticated = Boolean(result);
if (result) {
this.updateContext({ ...result, initialized: true });
}
else {
this.clearContext();
}
this.initialized = true;
this.initializing = false;
await this.resubscribeAllChannels();
this.emit(utils_1.ClientEvents.INITIALIZED, result);
return true;
}
/**
* The login method is always called via http so a http-only cookie can be set if the user so prefers.
*/
async login(params, opts) {
const response = await this.call(utils_1.Methods.RPC_LOGIN, params, {
...opts,
http: true,
});
if (!response || (0, isEmpty_1.default)(response)) {
throw new Error(utils_1.Errors.AUTHENTICATION_FAILED);
}
if ((0, isPlainObject_1.default)(response)) {
await this.setContextAndReInit(response);
}
}
async logout() {
await this.call(utils_1.Methods.RPC_LOGOUT);
this.authenticated = false;
this.clearContext();
this.emit(utils_1.ClientEvents.LOGOUT, false);
}
async resubscribeAllChannels() {
for (const [, channel] of this.channels) {
await channel.resubscribe();
}
}
async disconnect() {
return this.close();
}
/**
* Calls a method without expecting a return value.
*/
void(method, params, { http, httpFallback = true } = {}) {
return new Promise((resolve, reject) => {
const uuid = utils_1.Presentation.uuid();
const payload = {
type: utils_1.PayloadType.METHOD,
uuid,
method,
params,
void: true,
};
this.emit(utils_1.ClientEvents.OUTBOUND_MESSAGE, payload);
if (http || (!this.clientSocket.ready && httpFallback)) {
return this.clientHttp.request(payload, null, reject);
}
this.clientSocket.send(utils_1.Presentation.encode(payload));
resolve();
});
}
async call(method, params, options) {
const { timeout = 20000, http, httpFallback = true, ignoreInit = false, maxRetries = 0, delayBetweenRetriesMs = 3000, } = options ?? {};
// It should wait for the client to initialize before calling any method.
if (!ignoreInit && !this.initialized && method !== utils_1.Methods.RPC_INIT) {
try {
console.log('Helene: Waiting for initialization');
await this.waitFor(utils_1.ClientEvents.INITIALIZED, Math.floor(timeout / 2));
}
catch {
throw new Error('Helene: Client not initialized');
}
}
let lastError;
for (let attempt = 0; attempt < 1 + maxRetries; attempt++) {
try {
return await new Promise((resolve, reject) => {
const uuid = utils_1.Presentation.uuid();
const payload = { uuid, type: utils_1.PayloadType.METHOD, method, params };
this.emit(utils_1.ClientEvents.OUTBOUND_MESSAGE, payload);
if (http || (!this.clientSocket.ready && httpFallback)) {
return this.clientHttp.request(payload, resolve, reject);
}
this.clientSocket.send(utils_1.Presentation.encode(payload));
const timeoutId = setTimeout(() => {
const promise = this.queue.dequeue(uuid);
promise.reject(new Error('Result Timeout'));
}, timeout);
this.timeouts.add(timeoutId);
this.queue.enqueue(uuid, {
method: method,
resolve,
reject: this.errorHandler
? error => {
this.errorHandler(error);
reject(error);
}
: reject,
timeoutId,
});
});
}
catch (error) {
lastError = error;
if (attempt > 0) {
console.log(`Attempt ${attempt + 1} failed with error: ${error.message}`);
}
if (attempt + 1 >= maxRetries) {
throw lastError;
}
await new Promise(resolve => setTimeout(resolve, delayBetweenRetriesMs));
}
}
}
typed(types) {
return this;
}
combine(methods) {
return this;
}
handleError(payload) {
if (payload.uuid) {
const promise = this.queue.dequeue(payload.uuid);
if (!promise) {
if (this.errorHandler) {
this.errorHandler(payload);
}
return;
}
promise.reject(payload);
clearTimeout(promise.timeoutId);
this.timeouts.delete(promise.timeoutId);
}
}
handleEvent(payload) {
this.debugger('Event Received', payload);
return this.channel(payload.channel).emit(payload.event, payload.params);
}
handleResult(payload) {
const promise = this.queue.dequeue(payload.uuid);
if (!promise)
return;
clearTimeout(promise.timeoutId);
this.timeouts.delete(promise.timeoutId);
promise.resolve(payload.result);
}
payloadRouter(payload) {
this.emit(utils_1.ClientEvents.INBOUND_MESSAGE, payload);
switch (payload.type) {
case utils_1.PayloadType.ERROR:
return this.handleError(payload);
case utils_1.PayloadType.EVENT:
return this.handleEvent(payload);
case utils_1.PayloadType.RESULT:
return this.handleResult(payload);
}
}
/**
* Generates a URL path from string parts. The last argument can be a query
* string object definition.
*/
href(...path) {
let queryString = '';
if ((0, isPlainObject_1.default)((0, last_1.default)(path))) {
const params = path.pop();
queryString = '?'.concat(query_string_1.default.stringify(params));
}
if (path.some(isPlainObject_1.default))
throw new Error('Parameters are only allowed in the last argument.');
return `${this.clientHttp.host}/${path
.join('/')
.replace(/^\/|\/{2,}/, '')}${queryString}`;
}
channel(name = utils_1.NO_CHANNEL) {
if ((0, isObject_1.default)(name) &&
name.constructor.name === 'ObjectId' &&
(0, isFunction_1.default)(name.toString)) {
name = name.toString();
}
if (!name || !(0, isString_1.default)(name))
return null;
if (name === utils_1.NO_CHANNEL)
return this;
if (this.channels.has(name))
return this.channels.get(name);
const channel = new client_channel_1.ClientChannel(name);
channel.setClient(this);
this.channels.set(name, channel);
return channel;
}
isConnected() {
return new Promise(resolve => {
if (this.connected)
return resolve(true);
this.once(utils_1.ClientEvents.INITIALIZED, () => resolve(true));
});
}
async attachDevTools() {
const generateId = () => (Date.now() + Math.random()).toString(36);
console.log('DevTools Attached');
this.on(utils_1.ClientEvents.OUTBOUND_MESSAGE, content => {
// @ts-ignore
window.__helene_devtools_log_message?.({
id: generateId(),
content: ejson_1.EJSON.stringify(content),
isOutbound: true,
timestamp: Date.now(),
});
});
this.on(utils_1.ClientEvents.INBOUND_MESSAGE, content => {
// @ts-ignore
window.__helene_devtools_log_message?.({
id: generateId(),
content: ejson_1.EJSON.stringify(content),
isInbound: true,
timestamp: Date.now(),
});
});
}
fetch(url, options) {
return fetch(url, (0, merge_1.default)({
headers: {
[utils_1.TOKEN_HEADER_KEY]: this.context.token,
},
}, options));
}
}
exports.Client = Client;
//# sourceMappingURL=client.js.map