UNPKG

helene

Version:
415 lines 15.4 kB
"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