diff --git a/src/Action.ts b/src/Action.ts new file mode 100644 index 0000000..6374a90 --- /dev/null +++ b/src/Action.ts @@ -0,0 +1,34 @@ +import {MessageCommandErrorResult} from "./Messages"; +import {clientServiceLogger} from "./Logging"; + +export type ActionResult = { + unwrap() : T; +} & ({ + status: "success", + result: T +} | { + status: "error", + result: MessageCommandErrorResult +}); + + +export function createErrorResult(result: MessageCommandErrorResult) : ActionResult { + return { + status: "error", + result: result, + unwrap(): T { + clientServiceLogger.logError("Tried to unwrap an action which failed: %o", result); + throw "action failed with " + result.type; + } + } +} + +export function createResult(result: T) : ActionResult { + return { + status: "success", + result: result, + unwrap(): T { + return result; + } + } +} \ No newline at end of file diff --git a/src/ClientService.ts b/src/ClientService.ts index b94bf65..d57ae7c 100644 --- a/src/ClientService.ts +++ b/src/ClientService.ts @@ -9,6 +9,7 @@ import { import {geoLocationProvider} from "./GeoLocation"; import {clientServiceLogger} from "./Logging"; import {ClientServiceConnection} from "./Connection"; +import {Registry} from "tc-events"; export type LocalAgent = { clientVersion: string, @@ -25,9 +26,17 @@ export interface ClientServiceConfig { generateHostInfo() : LocalAgent; } +export interface ClientServiceEvents { + /** Client service session has successfully be initialized */ + notify_session_initialized: {}, + /** The current active client service session has been closed */ + notify_session_closed: {} +} + export class ClientServices { readonly config: ClientServiceConfig; - private connection: ClientServiceConnection; + readonly events: Registry; + private readonly connection: ClientServiceConnection; private sessionInitialized: boolean; private retryTimer: any; @@ -36,6 +45,7 @@ export class ClientServices { private initializeLocaleId: number; constructor(config: ClientServiceConfig) { + this.events = new Registry(); this.config = config; this.initializeAgentId = 0; this.initializeLocaleId = 0; @@ -44,6 +54,9 @@ export class ClientServices { this.connection = new ClientServiceConnection(5000); this.connection.events.on("notify_state_changed", event => { if(event.newState !== "connected") { + if(this.sessionInitialized) { + this.events.fire("notify_session_closed"); + } this.sessionInitialized = false; return; } @@ -60,20 +73,30 @@ export class ClientServices { return; } - this.sendInitializeAgent().then(undefined); + this.sendInitializeAgent().then(status => { + switch (status) { + case "aborted": + return; + + case "error": + clientServiceLogger.logError("Failed to initialize session. Closing it and trying again in 60 seconds."); + this.scheduleRetry(60 * 1000); + return; + + case "success": + this.sessionInitialized = true; + this.events.fire("notify_session_initialized"); + return; + } + }); + + /* The locale does not really matter for the session so just run it async */ this.sendLocaleUpdate().then(undefined); }); }); - this.connection.events.on("notify_notify_received", event => { - switch (event.notify.type) { - case "NotifyClientsOnline": - this.handleNotifyClientsOnline(event.notify.payload); - break; - - default: - return; - } + this.connection.registerNotifyHandler("NotifyClientsOnline", notify => { + clientServiceLogger.logInfo("Received user count update: %o", notify); }); } @@ -89,6 +112,10 @@ export class ClientServices { this.initializeLocaleId++; } + getConnection() : ClientServiceConnection { + return this.connection; + } + private scheduleRetry(time: number) { this.stop(); @@ -97,12 +124,13 @@ export class ClientServices { /** * Returns as soon the result indicates that something else went wrong rather than transmitting. + * Note: This will not throw an exception! * @param command * @param retryInterval */ private async executeCommandWithRetry(command: MessageCommand, retryInterval: number) : Promise { while(true) { - const result = await this.connection.executeCommand(command); + const result = await this.connection.executeMessageCommand(command); switch (result.type) { case "ServerInternalError": case "CommandEnqueueError": @@ -133,7 +161,10 @@ export class ClientServices { } } - private async sendInitializeAgent() { + /** + * @returns `true` if the session agent has been successfully initialized. + */ + private async sendInitializeAgent() : Promise<"success" | "aborted" | "error"> { const taskId = ++this.initializeAgentId; const hostInfo = this.config.generateHostInfo(); @@ -148,12 +179,22 @@ export class ClientServices { if(this.initializeAgentId !== taskId) { /* We don't want to send that stuff any more */ - return; + return "aborted"; } - this.executeCommandWithRetry({ type: "SessionInitializeAgent", payload }, 2500).then(result => { + const result = await this.executeCommandWithRetry({ type: "SessionInitializeAgent", payload }, 2500); + if(this.initializeAgentId !== taskId) { + /* We don't want to send that stuff any more */ + return "aborted"; + } + + if(result.type === "Success") { + clientServiceLogger.logTrace("Agent initialized", result); + return "success"; + } else { clientServiceLogger.logTrace("Agent initialize result: %o", result); - }); + return "error"; + } } private async sendLocaleUpdate() { @@ -173,12 +214,11 @@ export class ClientServices { return; } - this.connection.executeCommand({ type: "SessionUpdateLocale", payload }).then(result => { - clientServiceLogger.logTrace("Agent local update result: %o", result); - }); - } + const result = await this.connection.executeCommand("SessionUpdateLocale", payload); + if(this.initializeLocaleId !== taskId) { + return; + } - private handleNotifyClientsOnline(notify: NotifyClientsOnline) { - clientServiceLogger.logInfo("Received user count update: %o", notify); + clientServiceLogger.logTrace("Agent local update result: %o", result); } } \ No newline at end of file diff --git a/src/ClientServiceInvite.ts b/src/ClientServiceInvite.ts new file mode 100644 index 0000000..65e1ab0 --- /dev/null +++ b/src/ClientServiceInvite.ts @@ -0,0 +1,80 @@ +import {ClientServices} from "./ClientService"; +import {ActionResult, createErrorResult, createResult} from "./Action"; + +export type InviteLinkInfo = { + linkId: string, + + timestampCreated: number, + timestampDeleted: number, + + + amountViewed: number, + amountClicked: number, + + propertiesConnect: {[key: string]: string}, + propertiesInfo: {[key: string]: string}, +}; +export class ClientServiceInvite { + private readonly handle: ClientServices; + + constructor(handle: ClientServices) { + this.handle = handle; + } + + async createInviteLink(connectProperties: {[key: string]: string}, infoProperties: {[key: string]: string}, createNew: boolean) : Promise> { + const connection = this.handle.getConnection(); + + const notify = connection.catchNotify("NotifyInviteCreated"); + const result = await connection.executeCommand("InviteCreate", { + new_link: createNew, + properties_connect: connectProperties, + properties_info: infoProperties + }); + const notifyResult = notify(); + + if(result.type !== "Success") { + return createErrorResult(result); + } + + if(notifyResult.status === "fail") { + return createErrorResult({ type: "GenericError", error: "failed to receive notify" }); + } + + return createResult({ + adminToken: notifyResult.value.admin_token, + linkId: notifyResult.value.link_id + }); + } + + async queryInviteLink(linkId: string, registerView: boolean) : Promise> { + const connection = this.handle.getConnection(); + + const notify = connection.catchNotify("NotifyInviteInfo", notify => notify.link_id === linkId); + const result = await connection.executeCommand("InviteQueryInfo", { + link_id: linkId, + register_view: registerView + }); + const notifyResult = notify(); + + if(result.type !== "Success") { + return createErrorResult(result); + } + + if(notifyResult.status === "fail") { + return createErrorResult({ type: "GenericError", error: "failed to receive notify" }); + } + + return createResult({ + linkId: notifyResult.value.link_id, + + amountClicked: notifyResult.value.amount_clicked, + amountViewed: notifyResult.value.amount_viewed, + + timestampCreated: notifyResult.value.timestamp_created, + timestampDeleted: notifyResult.value.timestamp_deleted, + + propertiesConnect: notifyResult.value.properties_connect, + propertiesInfo: notifyResult.value.properties_info, + }); + } +} \ No newline at end of file diff --git a/src/Connection.ts b/src/Connection.ts index 58f69aa..ad4e343 100644 --- a/src/Connection.ts +++ b/src/Connection.ts @@ -12,9 +12,11 @@ type PendingCommand = { interface ClientServiceConnectionEvents { notify_state_changed: { oldState: ConnectionState, newState: ConnectionState }, - notify_notify_received: { notify: MessageNotify } } +type NotifyPayloadType = Extract["payload"]; +type CommandPayloadType = Extract["payload"]; + let tokenIndex = 0; export class ClientServiceConnection { readonly events: Registry; @@ -25,6 +27,7 @@ export class ClientServiceConnection { private connection: WebSocket; private pendingCommands: {[key: string]: PendingCommand} = {}; + private notifyHandler: {[key: string]: ((event) => void)[]} = {}; constructor(reconnectInterval: number) { this.events = new Registry(); @@ -34,6 +37,7 @@ export class ClientServiceConnection { destroy() { this.disconnect(); this.events.destroy(); + this.notifyHandler = {}; } getState() : ConnectionState { @@ -122,7 +126,7 @@ export class ClientServiceConnection { } } - async executeCommand(command: MessageCommand) : Promise { + async executeMessageCommand(command: MessageCommand) : Promise { if(this.connectionState !== "connected") { return { type: "ConnectionClosed" }; } @@ -153,6 +157,83 @@ export class ClientServiceConnection { }); } + async executeCommand(command: K, payload: CommandPayloadType) : Promise { + return await this.executeMessageCommand({ type: command as any, payload: payload as any }); + } + + registerNotifyHandler(notify: K, callback: (notify: NotifyPayloadType) => void) : () => void { + const handler = this.notifyHandler[notify] || (this.notifyHandler[notify] = []); + handler.push(callback); + + return () => this.unregisterNotifyHandler(notify, callback as any); + } + + unregisterNotifyHandler(callback: (notify: NotifyPayloadType) => void); + unregisterNotifyHandler(notify: K, callback: (notify: NotifyPayloadType) => void); + unregisterNotifyHandler(notifyOrCallback, callback?) { + if(typeof notifyOrCallback === "string") { + const handler = this.notifyHandler[notifyOrCallback]; + if(!handler) { + return; + } + + const index = handler.indexOf(callback); + if(index === -1) { + return; + } + + handler.splice(index); + if(handler.length === 0) { + delete this.notifyHandler[notifyOrCallback]; + } + } else { + for(const key of Object.keys(this.notifyHandler)) { + this.unregisterNotifyHandler(key as any, notifyOrCallback); + } + } + } + + catchNotify(notify: K, filter?: (value: NotifyPayloadType) => boolean) : () => ({ status: "success", value: NotifyPayloadType } | { status: "fail" }) { + /* + * Note: + * The current implementation allows the user to forget about the callback without causing any memory leaks. + * The memory might still leak if the registered notify never triggered. + */ + const handlers = this.notifyHandler[notify] || (this.notifyHandler[notify] = []); + const resultContainer = { result: null }; + + const handler = notify => { + if(filter && !filter(notify)) { + return; + } + + resultContainer.result = notify; + unregisterHandler(); + }; + + const unregisterHandler = () => { + const index = handlers.indexOf(handler); + if(index !== -1) { + handlers.remove(handler); + } + } + + handlers.push(handler); + return () => { + unregisterHandler(); + if(resultContainer.result === null) { + return { + status: "fail" + }; + } else { + return { + status: "success", + value: resultContainer.result + }; + } + } + } + private handleConnectFail() { this.disconnect(); this.executeReconnect(); @@ -195,7 +276,16 @@ export class ClientServiceConnection { clientServiceLogger.logWarn("Received command result for unknown token: %o", data.token); } } else if(data.type === "Notify") { - this.events.fire("notify_notify_received", { notify: data.notify }); + const handlers = this.notifyHandler[data.notify.type]; + if(typeof handlers !== "undefined") { + for(const handler of [...handlers]) { + try { + handler(data.notify.payload); + } catch (error) { + clientServiceLogger.logError("Failed to invoke notify handler for %s: %o", data.notify, error); + } + } + } } else { clientServiceLogger.logWarn("Received message with invalid type: %o", (data as any).type); } diff --git a/src/Messages.ts b/src/Messages.ts index d372b36..1fbfdf0 100644 --- a/src/Messages.ts +++ b/src/Messages.ts @@ -34,6 +34,8 @@ export type MessageCommandResult = | { type: "InviteKeyInvalid"; fields: string } | { type: "InviteKeyNotFound" }; +export type MessageCommandErrorResult = Exclude; + export type MessageNotify = | { type: "NotifyClientsOnline"; payload: NotifyClientsOnline } | { type: "NotifyInviteCreated"; payload: NotifyInviteCreated } @@ -53,7 +55,7 @@ export type CommandSessionInitializeAgent = { session_type: ClientSessionType; p export type CommandSessionUpdateLocale = { ip_country: string | null; selected_locale: string | null; local_timestamp: number }; -export type CommandInviteQueryInfo = { link_id: string }; +export type CommandInviteQueryInfo = { link_id: string, register_view: boolean }; export type CommandInviteLogAction = { click_type: number }; diff --git a/src/index.ts b/src/index.ts index dfad1b9..7b9df5e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,5 @@ export { ClientServiceConfig, ClientServices, LocalAgent } from "./ClientService"; export { ClientServiceLogger, setClientServiceLogger } from "./Logging"; -export { ClientSessionType } from "./Messages"; \ No newline at end of file +export { ClientSessionType } from "./Messages"; + +export { ClientServiceInvite, InviteActionResult, InviteLinkInfo, InviteCreateResult } from "./ClientServiceInvite"; \ No newline at end of file