diff --git a/default.nix b/default.nix index f7ae062..9408fb7 100644 --- a/default.nix +++ b/default.nix @@ -17,6 +17,13 @@ let pkgs.haskellPackages.channalu pkgs.haskellPackages.haskell-language-server pkgs.haskellPackages.implicit-hie + + # for icepeak-ts-client dev tools, npm, npx + pkgs.nodejs_20 + + # for icepeak-ts-client langauge server + pkgs.nodePackages_latest.typescript-language-server + pkgs.nodePackages_latest.typescript ]; withHoogle = true; diff --git a/icepeak-ts-client/.gitignore b/icepeak-ts-client/.gitignore new file mode 100644 index 0000000..76add87 --- /dev/null +++ b/icepeak-ts-client/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist \ No newline at end of file diff --git a/icepeak-ts-client/lib/icepeak-core.mts b/icepeak-ts-client/lib/icepeak-core.mts new file mode 100644 index 0000000..9e43174 --- /dev/null +++ b/icepeak-ts-client/lib/icepeak-core.mts @@ -0,0 +1,552 @@ +export { +type TokenRequestError, +type ExtraTokenData, +type Token, + +type SubscriptionRef, +type OnUpdate, +type OnSuccess, +type OnFailure, + +type FailedSubscribe, +type SubscriptionResult, + +createIcepeakCore, +type IcepeakCore, +type IcepeakCoreConfig, +type FetchTokenFn, +type CalculateRetryFn, +type LogFn, +type LogType +} + +import * as icepeak_payload from "./icepeak-payload.mjs"; + +import type * as ws from "ws"; + +// Named Types + +type TokenRequestError = { tokenRequestError: T }; +type ExtraTokenData = { extraTokenData: T }; +type Token = { token: string }; + +// SubscriptionRef Public Interface + +type SubscriptionRef = { + readonly path : string; + onUpdate: OnUpdate; + onSuccess: OnSuccess; + onFailure: OnFailure + readonly subscribe: (extra: FetchTokenExtraData) => SubscriptionResult; + readonly unusubscribe: () => void; +} + +type OnUpdate = (updatedValue: any) => void; +type OnSuccess = (success: icepeak_payload.SubscribeSuccess) => void; +type OnFailure = (failure: FailedSubscribe) => void; + +type FailedSubscribe = + | TokenRequestError + | icepeak_payload.SubscribeError; + +type SubscriptionResult = + | "SendingRequest" + | "AlreadySubscribed" + | "SubscriptionAlreadyInProgress"; + +// Subscription State + +type IcepeakCoreState = { + pathSubscriptions: PathSubscriptions, + wsConnState: WsConnState, +} + +type PathSubscriptions = { + [path: string]: PathSubscriptionState; +}; + +type PathSubscriptionState = { + readonly path: string; + status: SubscriptionStatus; + lastUsedExtraTokenData: ExtraTokenData; + subscribers: Set>; +}; + +type SubscriptionStatus = + | "Subscribed" + | "RequestInProgress" + | "NotSubscribed" + | "SendingRequestAfterIcepeakInitialised"; + +type WsConnState = + | WsConnUninitialised + | WsConnConnecting + | WsConnConnected + | WsConnClosed + | WsConnRetrying; + +// State when Icepeak object is first constructed +// State will go into 'Connecting' when the first subscribe() happens +type WsConnUninitialised = { connState: "Uninitialised"; }; +// The socket connection establishment is in flight to the server. +type WsConnConnecting = { connState: "Connecting"; }; +// The socket will retry the connection after set timeout expires +type WsConnRetrying = { connState: "Retrying"; }; +// After there has been an internal error (ClosedEvent) +// or there was a socket error but not retried (ErrorEvent) +// An icepeak `subscribe` may semi-manually trigger a reconnect. +type WsConnClosed = { connState: "Closed"; connError: any; }; +type WsConnConnected = { connState: "Connected"; wsConn: ws.WebSocket; }; + +// IcepeakCore Config + +type IcepeakCoreConfig = { + websocketUrl: string, + websocketConstructor: (url: string) => ws.WebSocket, + fetchTokenFn: FetchTokenFn, + calculateRetry: CalculateRetryFn, + logger: LogFn +} + +type FetchTokenFn = ( + path: string, + extraTokenData: ExtraTokenData, +) => Promise> + +// Returns the number of milliseconds to wait before retrying. +type CalculateRetryFn = (event: ws.ErrorEvent | ws.CloseEvent) => null | number + +type LogFn = (logType : LogType, logMessage : string, extra? : unknown) => void +type LogType = "Debug" | "InternalError" | "UserError" + + +// IcepeakCore Public Interface + +type IcepeakCore = { + createSubscriptionRef: (path: string) => SubscriptionRef + destroy: () => void +} + +function createIcepeakCore ( + config: IcepeakCoreConfig +) : IcepeakCore { + + const icepeakCorePrivate : IcepeakCorePrivate = { + config : config, + state : { pathSubscriptions: {}, wsConnState: { connState: "Uninitialised" } }, + + connectWs : connectWs, + connectWsOnOpen : connectWsOnOpen, + connectWsOnError : connectWsOnError, + onWsMessageEvent : onWsMessageEvent, + + onUpdatePayload : onUpdatePayload, + onSubscribeResponse : onSubscribeResponse, + onUnsubscribeResponse : onUnsubscribeResponse, + + onWsErrorOrClose : onWsErrorOrClose, + + subscribe : subscribe, + unsubscribe : unsubscribe, + syncSubscribers : syncSubscribers, + + sendSubscribe : sendSubscribe + } + + const icepeakCore = { + createSubscriptionRef: (createSubscriptionRef as + (path: string) => SubscriptionRef + ).bind(icepeakCorePrivate), + destroy: destroy + .bind(icepeakCorePrivate) + }; + + return icepeakCore +} + +// IcepeakCore Private Interface + +type IcepeakCorePrivate = { + config : IcepeakCoreConfig + state : IcepeakCoreState + + connectWs : () => Promise + connectWsOnOpen : (openedWsConn: ws.WebSocket) => void + connectWsOnError : ( + event: ws.ErrorEvent, + resolve: (res: void) => void, + reject: (rej: void) => void + ) => void + + onWsMessageEvent : (event: ws.MessageEvent) => void + onUpdatePayload : (update: icepeak_payload.ValueUpdate) => void + onSubscribeResponse : (subscribe : SubscribeResponse) => void + onUnsubscribeResponse : (unsubscribe : UnsubscribeResponse) => void + + onWsErrorOrClose : (event: ws.ErrorEvent | ws.CloseEvent) => void + + subscribe : ( + subscriptionRef: SubscriptionRef, + extraData: FetchTokenExtraData + ) => SubscriptionResult + + unsubscribe : (subscriptionRef: SubscriptionRef) => void + + syncSubscribers : (connectedWs: WsConnConnected) => void + + sendSubscribe : ( + connectedWs: WsConnConnected, + pathSubscription: PathSubscriptionState + ) => void +} + +// IcepeakCore Implementation + +function destroy(this: IcepeakCorePrivate): void { + this.config.logger("Debug", "Destroying...") + for (const path of Object.keys(this.state.pathSubscriptions)) { + this.config.logger("Debug", "Deleting path.", path) + delete this.state.pathSubscriptions[path]; + } + switch (this.state.wsConnState.connState) { + case "Connected": + this.state.wsConnState.wsConn.close(); + return; + case "Uninitialised": + case "Connecting": + case "Closed": + return; + } +} + +function createSubscriptionRef( + this: IcepeakCorePrivate, + path: string +): SubscriptionRef { + const subscriptionRef : SubscriptionRef = { + path : path, + onUpdate: () => {}, + onSuccess: () => {}, + onFailure: () => {}, + subscribe: extraData => this.subscribe(subscriptionRef, extraData), + unusubscribe: () => this.unsubscribe(subscriptionRef), + } + return subscriptionRef +} + +function connectWs(this: IcepeakCorePrivate): Promise { + return new Promise((resolve, reject) => { + this.config.logger("Debug", "Connecting to server...") + this.state.wsConnState = { connState: "Connecting" } + const wsConn = this.config.websocketConstructor(this.config.websocketUrl) + wsConn.onopen = _ => { this.connectWsOnOpen(wsConn); resolve() } + wsConn.onerror = e => this.connectWsOnError(e, resolve, reject) + }) +} + +function connectWsOnOpen(this: IcepeakCorePrivate, openedWsConn: ws.WebSocket): void { + this.config.logger("Debug", "Connected to server.") + const connectedWs: WsConnConnected = { connState: "Connected", wsConn: openedWsConn } + this.state.wsConnState = connectedWs + openedWsConn.onmessage = this.onWsMessageEvent.bind(this) + openedWsConn.onclose = this.onWsErrorOrClose.bind(this) + openedWsConn.onerror = this.onWsErrorOrClose.bind(this) + this.syncSubscribers(connectedWs) +} + +function connectWsOnError( + this : IcepeakCorePrivate, + errorEvent: ws.ErrorEvent, + resolve: (res: void) => void, + reject: (rej: void) => void +): void { + this.config.logger("Debug", "Connection initialisation error.", errorEvent) + const intervalMs = this.config.calculateRetry(errorEvent) + if (null == intervalMs) { + this.config.logger("Debug", "Will not retry, calculateRetry returned null.") + this.state.wsConnState = { connState: "Closed", connError: errorEvent } + } else { + this.config.logger("Debug", "Retrying, calculateRetry returned a number.", intervalMs) + this.state.wsConnState = { connState: "Retrying" } + setTimeout(() => this.connectWs().then(resolve).catch(reject), intervalMs) + } +} + +function onWsMessageEvent(this: IcepeakCorePrivate, event: ws.MessageEvent): void { + const mbIncomingPayload = icepeak_payload.parseMessageEvent(event) + if (mbIncomingPayload.type == "Fail") { + this.config.logger( + 'InternalError', + "Unexpected websocket payload from icepeak server.", + mbIncomingPayload.error) + return + } + const incomingPayload = mbIncomingPayload.value + this.config.logger("Debug", "Incoming payload.", [incomingPayload, this.state.pathSubscriptions]) + switch (incomingPayload.type) { + case "update": return this.onUpdatePayload(incomingPayload) + case "subscribe": return this.onSubscribeResponse(incomingPayload) + case "unsubscribe": return this.onUnsubscribeResponse(incomingPayload) + } +} + +type SubscribeResponse = icepeak_payload.SubscribeError | icepeak_payload.SubscribeSuccess +type UnsubscribeResponse = icepeak_payload.UnsubscribeError | icepeak_payload.UnsubscribeSuccess + +function onUpdatePayload( + this: IcepeakCorePrivate, + update : icepeak_payload.ValueUpdate +): void { + if (!(update.path in this.state.pathSubscriptions)) return + console.log(update.path) + const subs = this.state.pathSubscriptions[update.path].subscribers; + for (const sub of subs) sub.onUpdate(update.value); +} + +function onSubscribeResponse( + this: IcepeakCorePrivate, + subscribe : SubscribeResponse +): void { + switch (subscribe.code) { + case 200: + for (const subscribedPath of subscribe.paths) { + if (subscribedPath.path in this.state.pathSubscriptions) { + const subscriptionState = this.state.pathSubscriptions[subscribedPath.path] + subscriptionState.status = "Subscribed"; + for (const subscriber of subscriptionState.subscribers) + subscriber.onSuccess(subscribe) + } + } + return + + case 400: + case 401: + case 403: + if (!("paths" in subscribe)) { + this.config.logger('InternalError', + 'Subscribe response indicates malformed payload.', subscribe) + return + } + + for (const errorPath in subscribe.paths) { + if (errorPath in this.state.pathSubscriptions) { + const subscriptionState = this.state.pathSubscriptions[errorPath]; + subscriptionState.status = "NotSubscribed"; + for (const subscriber of subscriptionState.subscribers) + subscriber.onFailure(subscribe); + } + } + return + } +} + +function onUnsubscribeResponse( + this: IcepeakCorePrivate, + unsubscribe : UnsubscribeResponse +): void { + switch (unsubscribe.code) { + case 200: + return; + case 400: + this.config.logger('InternalError', + 'Unsubscribe response indicates malfored unsubscribe.', unsubscribe); + return; + } +} + +function onWsErrorOrClose( + this: IcepeakCorePrivate, + event: ws.ErrorEvent | ws.CloseEvent +) : void { + + for (const state of Object.values(this.state.pathSubscriptions)) + state.status = "NotSubscribed" + + if ("code" in event && event.code == 1000) { + this.state.wsConnState = { connState: "Closed", connError: event } + // 1000 indicates a normal closure, meaning that the purpose for + // which the connection was established has been fulfilled. + // https://www.rfc-editor.org/rfc/rfc6455.html#section-7.4 + this.config.logger('Debug', "Clean close.") + return + } + + if ("code" in event && event.code > 3000 && event.code < 3006) { + this.config.logger('InternalError', "Received close code indicating internal client error.") + return + } + + switch (this.state.wsConnState.connState) { + case "Connected": + this.state.wsConnState = { connState: "Closed", connError: event }; + this.config.logger('Debug', "Connection closed while connected, retrying...", event) + this.connectWs() + return + case "Retrying": + case "Uninitialised": + case "Connecting": + case "Closed": + this.config.logger('InternalError', "Unexpected close while socket not in connected state.", event) + return + } +} + + +function subscribe( + this: IcepeakCorePrivate, + subscriptionRef: SubscriptionRef, + extraData: FetchTokenExtraData +) : SubscriptionResult { + this.config.logger('Debug', "Registering subscriptionRef.", subscriptionRef) + + // Initialise the pathSubscription state if it doesnt exist for the path + if (!(subscriptionRef.path in this.state.pathSubscriptions)) { + this.state.pathSubscriptions[subscriptionRef.path] = { + path: subscriptionRef.path, + status: "NotSubscribed", + subscribers : new Set([subscriptionRef]), + lastUsedExtraTokenData: { extraTokenData : extraData } + } + } + const wsConnState = this.state.wsConnState + + if (wsConnState.connState == "Closed" || wsConnState.connState == "Uninitialised") + this.connectWs() + + switch (wsConnState.connState) { + case "Connected": + switch (this.state.pathSubscriptions[subscriptionRef.path].status) { + case "SendingRequestAfterIcepeakInitialised": + case "RequestInProgress": + this.state + .pathSubscriptions[subscriptionRef.path] + .subscribers.add(subscriptionRef) + return "SubscriptionAlreadyInProgress" + + case "Subscribed": + this.state + .pathSubscriptions[subscriptionRef.path] + .subscribers.add(subscriptionRef) + return "AlreadySubscribed" + + case "NotSubscribed": + this.config.logger('Debug', "NotSubscribed, but websocket connected.") + const pathState = this.state.pathSubscriptions[subscriptionRef.path] + + pathState.subscribers.add(subscriptionRef) + pathState.lastUsedExtraTokenData = { extraTokenData: extraData } + + this.sendSubscribe(wsConnState, pathState) + return "SendingRequest" + } + + case "Retrying": + case "Closed": + case "Connecting": + case "Uninitialised": + switch (this.state.pathSubscriptions[subscriptionRef.path].status) { + case "SendingRequestAfterIcepeakInitialised": + case "RequestInProgress": + this.state + .pathSubscriptions[subscriptionRef.path] + .subscribers.add(subscriptionRef) + return "SubscriptionAlreadyInProgress" + + case "NotSubscribed": + const pathState = this.state.pathSubscriptions[subscriptionRef.path] + pathState.subscribers.add(subscriptionRef) + pathState.lastUsedExtraTokenData = { extraTokenData: extraData } + pathState.status = "SendingRequestAfterIcepeakInitialised" + return "SendingRequest" + + case "Subscribed": // This case should not be possible + this.config.logger('InternalError', "Path state should not be subscribed while connection is closed.", + this.state.pathSubscriptions[subscriptionRef.path]) + return "AlreadySubscribed" + } + } +} + +function unsubscribe( + this: IcepeakCorePrivate, + subscriptionRef: SubscriptionRef +) : void { + if (!(subscriptionRef.path in this.state.pathSubscriptions)) return + + this.config.logger('Debug', 'Unregistering subscriptionRef.', subscriptionRef) + + this.state + .pathSubscriptions[subscriptionRef.path] + .subscribers.delete(subscriptionRef); + + if (this.state + .pathSubscriptions[subscriptionRef.path] + .subscribers.size == 0 + ) { + this.config.logger('Debug', + 'subscriptionRef count reached 0, removing path.', + this.state.pathSubscriptions[subscriptionRef.path]) + + delete this.state.pathSubscriptions[subscriptionRef.path] + + switch (this.state.wsConnState.connState) { + case "Connected": + const unsubscribePayload = icepeak_payload.createUnsubscribePayload([subscriptionRef.path]) + this.config.logger('Debug', 'Sending unsubscribe payload.', unsubscribePayload) + icepeak_payload.sendPayload(this.state.wsConnState.wsConn, unsubscribePayload); + return + case "Uninitialised": + case "Connecting": + case "Closed": + return + } + } +} + +function syncSubscribers( + this: IcepeakCorePrivate, + connectedWs: WsConnConnected +) : void { + this.config.logger('Debug', "Syncing subscribers...") + + for (const state of Object.values(this.state.pathSubscriptions)) { + if ( state.status == "NotSubscribed" || + state.status == "SendingRequestAfterIcepeakInitialised" + ) { + this.config.logger('Debug', "Syncing for path.", state) + this.sendSubscribe(connectedWs, state); + } + } +} + +function sendSubscribe( + this: IcepeakCorePrivate, + connectedWs: WsConnConnected, + pathSubscription: PathSubscriptionState +) : void { + this.config.logger('Debug', "Sending subscribe payload.", pathSubscription) + + const extraData = pathSubscription.lastUsedExtraTokenData; + const path = pathSubscription.path; + pathSubscription.status = "RequestInProgress"; + + this.config + .fetchTokenFn(path, extraData) + .then(tokenResponse => { + + if ("token" in tokenResponse) { + const subscribePayload = icepeak_payload + .createSubscribePayload([path], tokenResponse.token) + icepeak_payload + .sendPayload(connectedWs.wsConn, subscribePayload) + } + + if ("tokenRequestError" in tokenResponse) { + this.config.logger('Debug', "Received a token error.", tokenResponse) + this.config.logger('UserError', "Received a token error.", tokenResponse) + pathSubscription.status = "NotSubscribed"; + pathSubscription.subscribers.forEach( + subscriptionRef => subscriptionRef.onFailure(tokenResponse)); + } + }); +} diff --git a/icepeak-ts-client/lib/icepeak-payload.mts b/icepeak-ts-client/lib/icepeak-payload.mts new file mode 100644 index 0000000..e2e9989 --- /dev/null +++ b/icepeak-ts-client/lib/icepeak-payload.mts @@ -0,0 +1,228 @@ +export { + type ValueUpdate, + type SubscribeError, + type SubscribeSuccess, + type UnsubscribeError, + type UnsubscribeSuccess, + type SubscribePayload, + type UnsubscribePayload, + type IncomingPayload, + createSubscribePayload, + createUnsubscribePayload, + sendPayload, + parseMessageEvent, +}; + +import { + type Maybe, + just, + nothing, + type Either, + success, + fail, + parseArray, +} from "./util.mjs"; + +import * as ws from "ws"; + +type SubscribePayload = { type: string; paths: string[]; token: string }; + +function createSubscribePayload( + paths: [string], + token: string, +): SubscribePayload { + const subscribePayload = { + type: "subscribe", + paths: paths, + token: token, + }; + return subscribePayload; +} + +type UnsubscribePayload = { type: string; paths: string[] }; + +function createUnsubscribePayload(paths: [string]): UnsubscribePayload { + const unsubscribePayload = { + type: "unsubscribe", + paths: paths, + }; + return unsubscribePayload; +} + +function sendPayload( + conn: ws.WebSocket, + payload: UnsubscribePayload | SubscribePayload, +): void { + conn.send(JSON.stringify(payload)); +} + +type ValueUpdate = { + type: "update"; + path: string; + value: any; +}; + +type SubscribedPath = { path: string; value: any }; + +type SubscribeSuccess = { + type: "subscribe"; + paths: SubscribedPath[]; + code: 200; + message: string; +}; + +type SubscribeError = { + type: "subscribe"; + code: 400 | 401 | 403; + paths?: string[]; + message: string; + extra: any; +}; + +type UnsubscribeSuccess = { + type: "unsubscribe"; + code: 200; + paths: string[]; + message: string; +}; + +type UnsubscribeError = { + type: "unsubscribe"; + code: 400; + paths?: string[]; + message: string; + extra: any; +}; + +type IncomingPayload = + | SubscribeSuccess + | SubscribeError + | UnsubscribeSuccess + | UnsubscribeError + | ValueUpdate; + +function parseSubscribedPath(val: unknown): Maybe { + if (!(typeof val == "object" && val != null)) return nothing(); + if (!("path" in val && typeof val.path == "string" && "value" in val)) + return nothing(); + return just({ path: val.path, value: val.value }); +} + +function parseString(val: unknown): Maybe { + if (typeof val == "string") return success(val); + return nothing(); +} + +function parseMessageEvent( + event: ws.MessageEvent, +): Either { + const eventData = event.data; + if (typeof eventData != "string") return fail(eventData); + const json: unknown = JSON.parse(eventData); + if (!(typeof json == "object" && json != null)) return fail(eventData); + if (!("type" in json)) return fail(json); + switch (json.type) { + case "update": + if (!("path" in json && typeof json.path == "string" && "value" in json)) + return fail(json); + const update: ValueUpdate = { + type: "update", + path: json.path, + value: json.value, + }; + return success(update); + + case "subscribe": + if (!("code" in json && typeof json.code == "number")) return fail(json); + switch (json.code) { + case 200: + if (!("message" in json && typeof json.message == "string")) + return fail(json); + if (!("paths" in json && Array.isArray(json.paths))) + return fail(json); + + const subscribePaths = parseArray(json.paths, parseSubscribedPath); + if (subscribePaths.type == "Fail") return fail(json); + const happySubscribe: SubscribeSuccess = { + type: json.type, + paths: subscribePaths.value, + code: json.code, + message: json.message, + }; + return success(happySubscribe); + case 400: + case 401: + case 403: + if (!("message" in json && typeof json.message == "string")) + return fail(json); + if (!("extra" in json)) return fail(json); + if ("paths" in json && Array.isArray(json.paths)) { + const parsedArray = parseArray(json.paths, parseString); + if (parsedArray.type == "Fail") return fail(json); + const sadSubscribe: SubscribeError = { + type: json.type, + code: json.code, + paths: parsedArray.value, + message: json.message, + extra: json.extra, + }; + return success(sadSubscribe); + } else { + const sadSubscribe: SubscribeError = { + type: json.type, + code: json.code, + message: json.message, + extra: json.extra, + }; + return success(sadSubscribe); + } + } + return fail(json); + + case "unsubscribe": + if (!("code" in json && typeof json.code == "number")) return fail(json); + switch (json.code) { + case 200: + if (!("message" in json && typeof json.message == "string")) + return fail(json); + if (!("paths" in json && Array.isArray(json.paths))) + return fail(json); + + const subscribePaths = parseArray(json.paths, parseString); + if (subscribePaths.type == "Fail") return fail(json); + const happyUnsubscribe: UnsubscribeSuccess = { + type: json.type, + paths: subscribePaths.value, + code: json.code, + message: json.message, + }; + return success(happyUnsubscribe); + case 400: + if (!("message" in json && typeof json.message == "string")) + return fail(json); + if (!("extra" in json)) return fail(json); + if ("paths" in json && Array.isArray(json.paths)) { + const parsedArray = parseArray(json.paths, parseString); + if (parsedArray.type == "Fail") return fail(json); + const sadUnsubscribe: UnsubscribeError = { + type: json.type, + code: json.code, + paths: parsedArray.value, + message: json.message, + extra: json.extra, + }; + return success(sadUnsubscribe); + } else { + const sadUnsubscribe: UnsubscribeError = { + type: json.type, + code: json.code, + message: json.message, + extra: json.extra, + }; + return success(sadUnsubscribe); + } + } + return fail(json); + } + return fail(json); +} diff --git a/icepeak-ts-client/lib/icepeak.mts b/icepeak-ts-client/lib/icepeak.mts new file mode 100644 index 0000000..84088f6 --- /dev/null +++ b/icepeak-ts-client/lib/icepeak.mts @@ -0,0 +1,89 @@ +export { +type Icepeak, +type IcepeakConfig, +type Token, +type CalculateRetryFn, +createIcepeak, + +type Subscription +}; + +import { + type CalculateRetryFn, + type Token, + type LogFn, + type LogType, + + createIcepeakCore, + type IcepeakCore, + type IcepeakCoreConfig, + type ExtraTokenData, +} from "./icepeak-core.mjs"; + +import type * as ws from "ws"; + +type IcepeakConfig = { + websocketUrl: string, + websocketConstructor: (url: string) => ws.WebSocket, + fetchToken: (path: string) => Promise, + calculateRetry?: CalculateRetryFn, + logger?: LogFn, +} + +type Icepeak = { + readonly subscribe: ( + path: string, + onUpdate: (newValue : unknown) => void + ) => Subscription +} + +type Subscription = { + readonly unsubscribe : () => void +} + +// Constructing Icepeak + +function createIcepeak(config: IcepeakConfig): Icepeak { + const icepeakCoreConfig : IcepeakCoreConfig = { + websocketUrl: config.websocketUrl, + fetchTokenFn: (path: string) => config.fetchToken(path), + calculateRetry: config.calculateRetry ?? (_ => null), + websocketConstructor: config.websocketConstructor, + logger: config.logger ?? defaultLogger + } + + const icepeakCore = createIcepeakCore(icepeakCoreConfig) + + const icepeak : Icepeak = { + subscribe: ( + path: string, + onUpdate: (newValue : unknown) => void + ) => subscribe(icepeakCore, path, onUpdate) + }; + + return icepeak +} + +function defaultLogger( + logType: LogType, + logMessage : string, + logExtra? : unknown +) : void { + if (logType == 'UserError') { + logExtra + ? console.error(logMessage, logExtra) + : console.error(logMessage) + } +} + +function subscribe( + icepeakCore: IcepeakCore, + path: string, + onUpdate: (newValue : unknown) => void +): Subscription { + const subscriptionRef = icepeakCore.createSubscriptionRef(path) + subscriptionRef.onUpdate = onUpdate + subscriptionRef.subscribe(null) + const subscription : Subscription = { unsubscribe: subscriptionRef.unusubscribe } + return subscription +} diff --git a/icepeak-ts-client/lib/tsconfig.json b/icepeak-ts-client/lib/tsconfig.json new file mode 100644 index 0000000..bae975f --- /dev/null +++ b/icepeak-ts-client/lib/tsconfig.json @@ -0,0 +1,15 @@ +/* +Informed by: +- https://www.typescriptlang.org/docs/handbook/modules/guides/choosing-compiler-options.html#im-writing-a-library +- https://www.typescriptlang.org/docs/handbook/project-references.html#handbook-content +*/ + +{ + "extends": "../tsconfig.base.json", + "include": ["/**/*.mts"], + "compilerOptions": { + "composite": true, + } +} + + diff --git a/icepeak-ts-client/lib/useIcepeak.mts b/icepeak-ts-client/lib/useIcepeak.mts new file mode 100644 index 0000000..1478d3c --- /dev/null +++ b/icepeak-ts-client/lib/useIcepeak.mts @@ -0,0 +1,47 @@ +export { createUseIcepeak, createCoreUseIcepeak } + +import * as icepeak_core from './icepeak-core.mjs'; +import * as icepeak from './icepeak.mjs'; + +import { useState, useEffect } from 'react'; + +function createUseIcepeak(icepeakObject : icepeak.Icepeak) { + + function useIcepeak(path : string) { + const [ icepeakPathValue, setIcepeakPathValue ] + = useState(null); + + useEffect(() => { + const subscription = icepeakObject + .subscribe(path, setIcepeakPathValue) + return () => subscription.unsubscribe() + }) + return icepeakPathValue + } + + return useIcepeak +} + +function createCoreUseIcepeak( + icepeakCoreObject : icepeak_core.IcepeakCore, +) { + + function useIcepeak(path : string, tokenExtraData : TokenExtraData) { + const [ icepeakPathValue, setIcepeakPathValue ] + = useState(null); + + useEffect(() => { + const subscriptionRef + = icepeakCoreObject.createSubscriptionRef(path) + + subscriptionRef.onUpdate(setIcepeakPathValue) + subscriptionRef.subscribe(tokenExtraData) + + return () => subscriptionRef.unusubscribe() + }) + return icepeakPathValue + } + + return useIcepeak +} + diff --git a/icepeak-ts-client/lib/util.mts b/icepeak-ts-client/lib/util.mts new file mode 100644 index 0000000..768f8f4 --- /dev/null +++ b/icepeak-ts-client/lib/util.mts @@ -0,0 +1,42 @@ +export { + type Maybe, + just, + nothing, + type Either, + success, + fail, + parseArray, +}; + +type Maybe = { type: "Success"; value: T } | { type: "Fail" }; + +function just(t: T): Maybe { + return { type: "Success", value: t }; +} + +function nothing(): Maybe { + return { type: "Fail" }; +} + +function parseArray( + unknownArray: unknown[], + parser: (u: unknown) => Maybe, +): Maybe { + const tArray: T[] = []; + for (const val of unknownArray) { + const parsed = parser(val); + if (parsed.type == "Fail") return nothing(); + tArray.push(parsed.value); + } + return just(tArray); +} + +type Either = { type: "Fail"; error: E } | { type: "Success"; value: T }; + +function success(t: T): Either { + return { type: "Success", value: t }; +} + +function fail(e: E): Either { + return { type: "Fail", error: e }; +} diff --git a/icepeak-ts-client/package-lock.json b/icepeak-ts-client/package-lock.json new file mode 100644 index 0000000..76f438f --- /dev/null +++ b/icepeak-ts-client/package-lock.json @@ -0,0 +1,152 @@ +{ + "name": "icepeak-ts-client", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "icepeak-ts-client", + "version": "1.0.0", + "license": "BSD-3-Clause", + "dependencies": { + "react": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^18.2.61", + "@types/ws": "^8.5.10", + "prettier": "^3.2.5", + "typescript": "^5.3.3", + "ws": "^8.16.0" + } + }, + "node_modules/@types/node": { + "version": "20.11.16", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.16.tgz", + "integrity": "sha512-gKb0enTmRCzXSSUJDq6/sPcqrfCv2mkkG6Jt/clpn5eiCbKTY+SgZUxo+p8ZKMof5dCp9vHQUAB7wOUTod22wQ==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.11", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", + "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==", + "dev": true + }, + "node_modules/@types/react": { + "version": "18.2.61", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.61.tgz", + "integrity": "sha512-NURTN0qNnJa7O/k4XUkEW2yfygA+NxS0V5h1+kp9jPwhzZy95q3ADoGMP0+JypMhrZBTTgjKAUlTctde1zzeQA==", + "dev": true, + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/scheduler": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", + "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==", + "dev": true + }, + "node_modules/@types/ws": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", + "integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/prettier": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", + "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/react": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", + "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, + "node_modules/ws": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", + "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/icepeak-ts-client/package.json b/icepeak-ts-client/package.json new file mode 100644 index 0000000..928a707 --- /dev/null +++ b/icepeak-ts-client/package.json @@ -0,0 +1,28 @@ +{ + "name": "icepeak-ts-client", + "type": "module", + "version": "1.0.0", + "description": "TypeScript client for the Icepeak pub-sub server.", + "main": "./dist/lib/icepeak.mjs", + "scripts": { + "build": "npx tsc --build lib", + "build-test": "npx tsc --build test", + "test-icepeak": "npm run build-test && node ./dist/test/icepeak.spec.mjs", + "test-icepeak-core": "npm run build-test && node ./dist/test/icepeak-core.spec.mjs", + "watch": "npx tsc --watch ./lib/*.mts", + "format": "npx prettier --check ./lib/*.mts", + "format-fix": "npx prettier --write ./lib/*.mts" + }, + "author": "channable", + "license": "BSD-3-Clause", + "devDependencies": { + "@types/react": "^18.2.61", + "@types/ws": "^8.5.10", + "prettier": "^3.2.5", + "typescript": "^5.3.3", + "ws": "^8.16.0" + }, + "dependencies": { + "react": "^18.2.0" + } +} diff --git a/icepeak-ts-client/readme.md b/icepeak-ts-client/readme.md new file mode 100644 index 0000000..f5f4c83 --- /dev/null +++ b/icepeak-ts-client/readme.md @@ -0,0 +1,62 @@ +# Icepeak TypeScript Client + +The icepeak client provides 2 interfaces: +- `lib/icepeak-core.mts` the base interface. +- `lib/icepeak.mts` simplified wrapper over `icepeak-core`. + +There also exists a corresponding react hook for them in: +- `lib/useIcepeak.mts` + +The general usage pattern is to import the constructor for icepeak i.e `createIcepeakCore` (full) or `createIcepeak` (simple), and then you need to provide it as an argument the corresponding config, i.e `IcepeakCoreConfig`, or `IcepeakConfig` + + +## Example: Using the Simplified `icepeak` Interface + +```ts +import * as icepeak from "../lib/icepeak.mjs" + +// Minimal required config +const config : icepeak.IcepeakConfig = { + websocketUrl: "ws://localhost:3000/?method=reusable", + websocketConstructor: url => new WebSocket(url), + fetchToken: async (path: string) => { return "dummmy-token" } +} + +const icepeakObject = icepeak.createIcepeak(config); + +const subscription = icepeakObj.subscribe( + "/root/sub1", + val => console.log("I received an update!", val)) +} + +... + +subscription.unsubscribe() +``` + +## Full Configuration Settings + +```ts +// from icepeak-core.mts + +// Returns the number of milliseconds to wait before retrying. +type CalculateRetryFn = (event: ws.ErrorEvent | ws.CloseEvent) => null | number + +type LogFn = (logType : LogType, logMessage : string, extra? : unknown) => void +type LogType = "Debug" | "InternalError" | "UserError" + +type FetchTokenFn = ( + path: string, + extraTokenData: ExtraTokenData, +) => Promise> + +type IcepeakCoreConfig = { + websocketUrl: string, + websocketConstructor: (url: string) => ws.WebSocket, + fetchTokenFn: FetchTokenFn, + calculateRetry: CalculateRetryFn, + logger: LogFn +} +``` + +The `test` folder contains example usage for `icepeak-core`. diff --git a/icepeak-ts-client/test/icepeak-core.spec.mts b/icepeak-ts-client/test/icepeak-core.spec.mts new file mode 100644 index 0000000..a52f1e9 --- /dev/null +++ b/icepeak-ts-client/test/icepeak-core.spec.mts @@ -0,0 +1,35 @@ +import * as test_util from "./test-util.mjs" +import WebSocket from 'ws' + +import * as icepeak from "../lib/icepeak.mjs" +import * as icepeak_core from "../lib/icepeak-core.mjs" + +test_util.fill_node_event_queue() +test_util.handle_test_result(icepeakCoreTest()) + + +async function icepeakCoreTest() { + console.log("Starting Test") + const config = { + websocketUrl: "ws://localhost:3000/?method=reusable", + websocketConstructor: (url: string) => new WebSocket(url), + fetchTokenFn: test_util.fetch_dummy_token, + calculateRetry: () => null, + logger: test_util.log_everything + }; + + await test_util.put_data({}, "") + const w1 = new test_util.Wait() + const w2 = new test_util.Wait() + const icepeakCore = icepeak_core.createIcepeakCore(config); + const s1 = icepeakCore.createSubscriptionRef("root/sub1") + const s2 = icepeakCore.createSubscriptionRef("root") + s2.onUpdate = u => {console.log("S2 updated:", u)} + s2.subscribe(null) + s1.onSuccess = _ => {console.log("S1 subscribed"); w1.done()} + s1.onUpdate = u => {console.log("S1 updated:", u); w2.done()} + s1.subscribe(null) + await w1.wait + test_util.put_data(3, "/root/sub1") + await w2.wait +} diff --git a/icepeak-ts-client/test/icepeak.spec.mts b/icepeak-ts-client/test/icepeak.spec.mts new file mode 100644 index 0000000..f8db0df --- /dev/null +++ b/icepeak-ts-client/test/icepeak.spec.mts @@ -0,0 +1,45 @@ +import * as test_util from "./test-util.mjs" +import WebSocket from 'ws' + +import * as icepeak from "../lib/icepeak.mjs" + +test_util.fill_node_event_queue() +test_util.handle_test_result(icepeakTest()) + +async function icepeakTest() { + console.log("Starting Test") + + // Clear Icepeak server data + await test_util.put_data({}, "") + + const config : icepeak.IcepeakConfig = { + websocketUrl: "ws://localhost:3000/?method=reusable", + websocketConstructor: url => new WebSocket(url), + fetchToken: test_util.simplify_fetch_token(test_util.fetch_dummy_token), + logger: test_util.log_everything, + } + + const w1 = new test_util.Wait() + const w2 = new test_util.Wait() + const icepeakObj = icepeak.createIcepeak(config); + // const s1 = icepeakObj.subscribe("root", val => { console.log("s1 received:", val); w1.done() }) + + // await test_util.put_data(8, "/root") + // await test_util.put_data(11, "/root") + const s2 = icepeakObj.subscribe("/root/sub1", val => { console.log("s2 received:", val); w2.done() }) + const s3 = icepeakObj.subscribe("/root/sub1", val => { console.log("s3 received:", val); w2.done() }) + + // await test_util.put_data(12, "/root/sub1/sub2") + await test_util.put_data({"sub1": { "sub2": 11 }}, "/root") + await test_util.put_data({"sub1": { "sub2": 11 }}, "/root") + await test_util.put_data({"sub1": { "sub2": 11 }}, "/root") + // s1.unsubscribe() + // s2.unsubscribe() + // s3.unsubscribe() + + await w1.wait + await w2.wait + +} + + diff --git a/icepeak-ts-client/test/test-util.mts b/icepeak-ts-client/test/test-util.mts new file mode 100644 index 0000000..ef8e528 --- /dev/null +++ b/icepeak-ts-client/test/test-util.mts @@ -0,0 +1,87 @@ +export { +fetch_dummy_token, +put_data, +Wait, +fill_node_event_queue, +handle_test_result, +log_everything, +simplify_fetch_token +} + +import timers from "timers"; +import { setTimeout } from 'timers/promises'; + +import process from 'process'; +import child_process from 'child_process'; + +import util from 'util'; + +import WebSocket from 'ws' + +const exec = util.promisify(child_process.exec); + + +import type { FetchTokenFn, LogFn, Token } from "../lib/icepeak-core.mjs"; + +const log_everything : LogFn = + (logType, logMessage, extra) => { + extra + ? console.log(logType + "\n", logMessage, "\n", extra) + : console.log(logType + "\n", logMessage) + } + +const fetch_dummy_token : FetchTokenFn = + async (_path, _) => { + return { token: "dummy-token" } + }; + +// Adapt a 'IcepeakCore' FetchTokenFn for the simple fetch +// token function of the simpler 'Icepeak' interface +const simplify_fetch_token + : (f : FetchTokenFn) + => (( p : string ) => Promise) = f => + (path => f(path, { extraTokenData: null }) + .then(t => { if ("tokenRequestError" in t) throw t; return t })) + +function put_data(obj : any, path : string) : Promise { + return exec( + "curl -X PUT -H 'Content-Type: application/json' -d " + + `'${JSON.stringify(obj)}'` + + " http://localhost:3000" + + path) +} + +class Wait { + public wait : Promise; + + public done() : void { + this.resolve(); + } + + private resolve : () => void; + constructor(){ + this.resolve = () => { + console.error("'Wait' internal error: Author had faulty understanding of JS semantics.") + process.exit(1) + } + + // Is this asynchronous? Probably not? + this.wait = new Promise((resolve, _) => { + this.resolve = () => resolve(null) + }); + }; +} + +const fill_node_event_queue = () => { timers.setTimeout(fill_node_event_queue, 1000) } + +function handle_test_result( test : Promise ) : void { + test + .then(result => { + console.log("Test finished succesfuly with result:\n" + result) + process.exit(0) + }) + .catch(err => { + console.log("Test threw error:\n" + err) + process.exit(1) + }) +} diff --git a/icepeak-ts-client/test/tsconfig.json b/icepeak-ts-client/test/tsconfig.json new file mode 100644 index 0000000..9b7a36b --- /dev/null +++ b/icepeak-ts-client/test/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.base.json", + "references": [ { "path": "../lib" } ], + "include": ["/**/*.mts"], + + "compilerOptions": { + "composite": true, + } +} diff --git a/icepeak-ts-client/tsconfig.base.json b/icepeak-ts-client/tsconfig.base.json new file mode 100644 index 0000000..3ab3622 --- /dev/null +++ b/icepeak-ts-client/tsconfig.base.json @@ -0,0 +1,23 @@ +{ + "references": [ + { "path": "./lib" }, + { "path": "./test" } + ], + "compilerOptions": { + "rootDir": "./.", + "outDir": "./dist/", + + "target": "es2017", + "module": "nodenext", + + "verbatimModuleSyntax": true, + "forceConsistentCasingInFileNames": true, + + "declaration": true, + "declarationMap": true, + // "declarationDir": "./lib/types", + + "sourceMap": true, + "strict": true, + } +}