diff --git a/src/Index.res b/src/Index.res index 52691270d..ab59dcc8b 100644 --- a/src/Index.res +++ b/src/Index.res @@ -2,6 +2,7 @@ %%raw("import './index.css'") Sentry.initiateSentry(~dsn=GlobalVars.sentryDSN) +ServiceWorkerHelpers.registerSW()->ignore let app = switch ReactDOM.querySelector("#app") { | Some(container) => diff --git a/src/Utilities/LoggerUtils.res b/src/Utilities/LoggerUtils.res index c4ad63663..93d2c8d5b 100644 --- a/src/Utilities/LoggerUtils.res +++ b/src/Utilities/LoggerUtils.res @@ -87,6 +87,51 @@ let toSnakeCaseWithSeparator = (str, separator) => { ) } +let logFileToObj = (logFile: HyperLoggerTypes.logFile) => { + let getStringFromBool = val => val ? "true" : "false" + [ + ("timestamp", logFile.timestamp->JSON.Encode.string), + ( + "log_type", + switch logFile.logType { + | DEBUG => "DEBUG" + | INFO => "INFO" + | ERROR => "ERROR" + | WARNING => "WARNING" + | SILENT => "SILENT" + }->JSON.Encode.string, + ), + ("component", "WEB"->JSON.Encode.string), + ( + "category", + switch logFile.category { + | API => "API" + | USER_ERROR => "USER_ERROR" + | USER_EVENT => "USER_EVENT" + | MERCHANT_EVENT => "MERCHANT_EVENT" + }->JSON.Encode.string, + ), + ("source", logFile.source->convertToScreamingSnakeCase->JSON.Encode.string), + ("version", logFile.version->JSON.Encode.string), + ("value", logFile.value->JSON.Encode.string), + // ("internal_metadata", logFile.internalMetadata->JSON.Encode.string), + ("session_id", logFile.sessionId->JSON.Encode.string), + ("merchant_id", logFile.merchantId->JSON.Encode.string), + ("payment_id", logFile.paymentId->JSON.Encode.string), + ("app_id", logFile.appId->JSON.Encode.string), + ("platform", logFile.platform->convertToScreamingSnakeCase->JSON.Encode.string), + ("user_agent", logFile.userAgent->JSON.Encode.string), + ("event_name", logFile.eventName->eventNameToStrMapper->JSON.Encode.string), + ("browser_name", logFile.browserName->convertToScreamingSnakeCase->JSON.Encode.string), + ("browser_version", logFile.browserVersion->JSON.Encode.string), + ("latency", logFile.latency->JSON.Encode.string), + ("first_event", logFile.firstEvent->getStringFromBool->JSON.Encode.string), + ("payment_method", logFile.paymentMethod->convertToScreamingSnakeCase->JSON.Encode.string), + ] + ->Dict.fromArray + ->JSON.Encode.object +} + let defaultLoggerConfig: HyperLoggerTypes.loggerMake = { sendLogs: () => (), setClientSecret: _x => (), @@ -131,106 +176,201 @@ let defaultLoggerConfig: HyperLoggerTypes.loggerMake = { let saveLogsToIndexedDB = (logs: array) => { Promise.make((resolve, reject) => { - let request = openDBAndGetRequest(~dbName="HyperLogger", ~objectStoreName="logs") + switch openDBAndGetRequest(~dbName="HyperLogger", ~objectStoreName="logs") { + | Some(request) => + request->OpenDBRequest.onsuccess(_ => { + let db = OpenDBRequest.result(request) + + if logs->Array.length > 0 { + let transaction = db->DB.transaction(["logs"], "readwrite") + let store = transaction->Transaction.objectStore("logs") + + transaction->Transaction.oncomplete( + _ => { + db->DB.close + resolve() + }, + ) + + transaction->Transaction.onerror( + _ => { + db->DB.close + reject() + }, + ) + + logs->Array.forEach( + log => { + let _ = store->ObjectStore.put(log) + }, + ) + } else { + db->DB.close + reject() + } + }) - request->OpenDBRequest.onsuccess(_ => { - let db = OpenDBRequest.result(request) + request->OpenDBRequest.onerror(_ => { + reject() + }) + | None => reject() + } + }) +} + +let retrieveLogsFromIndexedDB = () => { + Promise.make((resolve, reject) => { + switch openDBAndGetRequest(~dbName="HyperLogger", ~objectStoreName="logs") { + | Some(request) => + request->OpenDBRequest.onsuccess(_ => { + let db = OpenDBRequest.result(request) + let transaction = db->DB.transaction(["logs"], "readonly") + let store = transaction->Transaction.objectStore("logs") + let getAllRequest = store->ObjectStore.getAll + + getAllRequest->Request.onsuccess( + _ => { + let result = Request.result(getAllRequest) + db->DB.close + resolve(result) + }, + ) + + getAllRequest->Request.onerror( + _ => { + db->DB.close + reject([]) + }, + ) + }) + + request->OpenDBRequest.onerror(_ => { + reject([]) + }) + | None => reject([]) + } + }) +} - if logs->Array.length > 0 { +let clearLogsFromIndexedDB = () => { + Promise.make((resolve, reject) => { + switch openDBAndGetRequest(~dbName="HyperLogger", ~objectStoreName="logs") { + | Some(request) => + request->OpenDBRequest.onsuccess(_ => { + let db = OpenDBRequest.result(request) let transaction = db->DB.transaction(["logs"], "readwrite") let store = transaction->Transaction.objectStore("logs") + let clearRequest = store->ObjectStore.clear - transaction->Transaction.oncomplete( + clearRequest->Request.onsuccess( _ => { db->DB.close resolve() }, ) - transaction->Transaction.onerror( + clearRequest->Request.onerror( _ => { db->DB.close reject() }, ) + }) - logs->Array.forEach( - log => { - let _ = store->ObjectStore.put(log) - }, - ) - } else { - db->DB.close + request->OpenDBRequest.onerror(_ => { reject() - } - }) - - request->OpenDBRequest.onerror(_ => { - reject() - }) + }) + | None => reject() + } }) } -let retrieveLogsFromIndexedDB = () => { +let retrieveAndClearLogsFromIndexedDB = () => { Promise.make((resolve, reject) => { - let request = openDBAndGetRequest(~dbName="HyperLogger", ~objectStoreName="logs") + switch openDBAndGetRequest(~dbName="HyperLogger", ~objectStoreName="logs") { + | Some(request) => + request->OpenDBRequest.onsuccess(_ => { + let db = OpenDBRequest.result(request) + let transaction = db->DB.transaction(["logs"], "readwrite") + let store = transaction->Transaction.objectStore("logs") + let getAllRequest = store->ObjectStore.getAll - request->OpenDBRequest.onsuccess(_ => { - let db = OpenDBRequest.result(request) - let transaction = db->DB.transaction(["logs"], "readonly") - let store = transaction->Transaction.objectStore("logs") - let getAllRequest = store->ObjectStore.getAll + getAllRequest->Request.onsuccess( + _ => { + let result = Request.result(getAllRequest) + let _ = store->ObjectStore.clear - getAllRequest->Request.onsuccess( - _ => { - let result = Request.result(getAllRequest) - db->DB.close - resolve(result) - }, - ) + transaction->Transaction.oncomplete( + _ => { + db->DB.close + resolve(result) + }, + ) - getAllRequest->Request.onerror( - _ => { - db->DB.close - reject([]) - }, - ) - }) - - request->OpenDBRequest.onerror(_ => { - reject([]) - }) + transaction->Transaction.onerror( + _ => { + db->DB.close + reject([]) + }, + ) + }, + ) + + getAllRequest->Request.onerror( + _ => { + db->DB.close + reject([]) + }, + ) + }) + + request->OpenDBRequest.onerror(_ => { + reject([]) + }) + | None => reject([]) + } }) } -let clearLogsFromIndexedDB = () => { +let saveRawLogsToIndexedDB = (logs: array) => { Promise.make((resolve, reject) => { - let request = openDBAndGetRequest(~dbName="HyperLogger", ~objectStoreName="logs") + if logs->Array.length === 0 { + resolve() + } else { + switch openDBAndGetRequest(~dbName="HyperLogger", ~objectStoreName="logs") { + | Some(request) => + request->OpenDBRequest.onsuccess(_ => { + let db = OpenDBRequest.result(request) + let transaction = db->DB.transaction(["logs"], "readwrite") + let store = transaction->Transaction.objectStore("logs") - request->OpenDBRequest.onsuccess(_ => { - let db = OpenDBRequest.result(request) - let transaction = db->DB.transaction(["logs"], "readwrite") - let store = transaction->Transaction.objectStore("logs") - let clearRequest = store->ObjectStore.clear + transaction->Transaction.oncomplete( + _ => { + db->DB.close + resolve() + }, + ) - clearRequest->Request.onsuccess( - _ => { - db->DB.close - resolve() - }, - ) + transaction->Transaction.onerror( + _ => { + db->DB.close + reject() + }, + ) - clearRequest->Request.onerror( - _ => { - db->DB.close - reject() - }, - ) - }) + logs->Array.forEach( + log => { + let _ = store->ObjectStore.put(log) + }, + ) + }) - request->OpenDBRequest.onerror(_ => { - reject() - }) + request->OpenDBRequest.onerror(_ => { + reject() + }) + | None => reject() + } + } }) } diff --git a/src/Window.res b/src/Window.res index f9685aa76..2191a607c 100644 --- a/src/Window.res +++ b/src/Window.res @@ -99,6 +99,26 @@ external removeEventListener: (string, 'ev => unit) => unit = "removeEventListen @val external windowOpen: (string, string, string) => Nullable.t = "open" @val external isSecureContext: bool = "isSecureContext" +type serviceWorkerRegistration + +module ServiceWorker = { + type t + @send + external postMessage: (t, {..}) => unit = "postMessage" + @get + external scriptURL: t => string = "scriptURL" +} + +module ServiceWorkerContainer = { + type t + + @get + external controller: t => Nullable.t = "controller" + + @send + external register: (t, string) => promise = "register" +} + /* Module Definitions */ module Navigator = { @val @scope("navigator") @@ -118,6 +138,9 @@ module Navigator = { @val @scope("navigator") external sendBeacon: (string, string) => unit = "sendBeacon" + + @val @scope(("window", "navigator")) + external serviceWorker: option = "serviceWorker" } module Location = { diff --git a/src/hyper-log-catcher/ErrorBoundary.res b/src/hyper-log-catcher/ErrorBoundary.res index 6319fe8ea..e541d18f4 100644 --- a/src/hyper-log-catcher/ErrorBoundary.res +++ b/src/hyper-log-catcher/ErrorBoundary.res @@ -131,7 +131,7 @@ module ErrorCard = { ) => { let beaconApiCall = data => { if data->Array.length > 0 { - let logData = data->Array.map(HyperLogger.logFileToObj)->JSON.Encode.array->JSON.stringify + let logData = data->Array.map(LoggerUtils.logFileToObj)->JSON.Encode.array->JSON.stringify Window.Navigator.sendBeacon(GlobalVars.logEndpoint, logData) } } diff --git a/src/hyper-log-catcher/HyperLogger.res b/src/hyper-log-catcher/HyperLogger.res index 4c0cfa3c0..43dda37dd 100644 --- a/src/hyper-log-catcher/HyperLogger.res +++ b/src/hyper-log-catcher/HyperLogger.res @@ -1,50 +1,6 @@ open HyperLoggerTypes open LoggerUtils -let logFileToObj = logFile => { - [ - ("timestamp", logFile.timestamp->JSON.Encode.string), - ( - "log_type", - switch logFile.logType { - | DEBUG => "DEBUG" - | INFO => "INFO" - | ERROR => "ERROR" - | WARNING => "WARNING" - | SILENT => "SILENT" - }->JSON.Encode.string, - ), - ("component", "WEB"->JSON.Encode.string), - ( - "category", - switch logFile.category { - | API => "API" - | USER_ERROR => "USER_ERROR" - | USER_EVENT => "USER_EVENT" - | MERCHANT_EVENT => "MERCHANT_EVENT" - }->JSON.Encode.string, - ), - ("source", logFile.source->convertToScreamingSnakeCase->JSON.Encode.string), - ("version", logFile.version->JSON.Encode.string), - ("value", logFile.value->JSON.Encode.string), - // ("internal_metadata", logFile.internalMetadata->JSON.Encode.string), - ("session_id", logFile.sessionId->JSON.Encode.string), - ("merchant_id", logFile.merchantId->JSON.Encode.string), - ("payment_id", logFile.paymentId->JSON.Encode.string), - ("app_id", logFile.appId->JSON.Encode.string), - ("platform", logFile.platform->convertToScreamingSnakeCase->JSON.Encode.string), - ("user_agent", logFile.userAgent->JSON.Encode.string), - ("event_name", logFile.eventName->eventNameToStrMapper->JSON.Encode.string), - ("browser_name", logFile.browserName->convertToScreamingSnakeCase->JSON.Encode.string), - ("browser_version", logFile.browserVersion->JSON.Encode.string), - ("latency", logFile.latency->JSON.Encode.string), - ("first_event", logFile.firstEvent->Utils.getStringFromBool->JSON.Encode.string), - ("payment_method", logFile.paymentMethod->convertToScreamingSnakeCase->JSON.Encode.string), - ] - ->Dict.fromArray - ->JSON.Encode.object -} - let getRefFromOption = val => { let innerValue = val->Option.getOr("") ref(innerValue) @@ -140,10 +96,9 @@ let make = (~sessionId=?, ~source: source, ~clientSecret=?, ~merchantId=?, ~meta } let sendCachedLogsFromIDB = async () => { try { - let logs = await retrieveLogsFromIndexedDB() + let logs = await retrieveAndClearLogsFromIndexedDB() if logs->Array.length > 0 { beaconApiCall(logs) - await clearLogsFromIndexedDB() } } catch { | _ => () @@ -171,22 +126,36 @@ let make = (~sessionId=?, ~source: source, ~clientSecret=?, ~merchantId=?, ~meta let sendLogsToIndexedDB = async () => { try { - let _ = await saveLogsToIndexedDB(mainLogFile) + let logs = Array.copy(mainLogFile) clearLogFile(mainLogFile) + let _ = await saveLogsToIndexedDB(logs) } catch { | _ => () } } - let sendLogsOverNetwork = async () => { + let fallbackToBeaconApiCall = logs => { + beaconApiCall(logs) + sendCachedLogsFromIDB()->ignore + } + + let sendLogsOverNetwork = () => { + let logs = Array.copy(mainLogFile) + clearLogFile(mainLogFile) try { - await sendCachedLogsFromIDB() - beaconApiCall(mainLogFile) - clearLogFile(mainLogFile) + if ServiceWorkerHelpers.isAvailable() { + ServiceWorkerHelpers.sendMessage({ + "type": "SEND_LOGS", + "logs": logs->Array.map(logFileToObj)->JSON.Encode.array, + }) + } else { + logs->fallbackToBeaconApiCall + } } catch { - | _ => () + | _ => logs->fallbackToBeaconApiCall } } + let rec sendLogs = () => { switch timeOut.contents { | Some(val) => { @@ -202,7 +171,7 @@ let make = (~sessionId=?, ~source: source, ~clientSecret=?, ~merchantId=?, ~meta } if networkStatus.isOnline { - sendLogsOverNetwork()->ignore + sendLogsOverNetwork() } else { sendLogsToIndexedDB()->ignore } diff --git a/src/libraries/IndexedDB.res b/src/libraries/IndexedDB.res index 040dcb160..e67a97ca2 100644 --- a/src/libraries/IndexedDB.res +++ b/src/libraries/IndexedDB.res @@ -6,8 +6,27 @@ type request<'a> type event module IndexedDB = { - @val @scope("window") external instance: 'a = "indexedDB" + @val @scope("window") external windowIndexedDB: option<'a> = "indexedDB" + @val @scope("self") external selfIndexedDB: option<'a> = "indexedDB" @send external open_: ('a, string, int) => openDBRequest = "open" + + let getIndexedDBInstance = () => { + let windowIDB = try {windowIndexedDB} catch { + | _ => None + } + let selfIDB = try {selfIndexedDB} catch { + | _ => None + } + + switch (windowIDB, selfIDB) { + | (Some(idb), _) => Some(idb) + | (_, Some(idb)) => Some(idb) + | _ => { + Console.error("IndexedDB is not supported in this environment:") + None + } + } + } } module OpenDBRequest = { @@ -71,29 +90,19 @@ let getErrorMessageFromEvent = event => { } let openDBAndGetRequest = (~dbName, ~objectStoreName) => { - let request = IndexedDB.instance->IndexedDB.open_(dbName, 1) - - request->OpenDBRequest.onupgradeneeded(event => { - let db = getDbFromEvent(event) - let _ = - db->DB.createObjectStore(objectStoreName, {"keyPath": "timestamp", "autoIncrement": true}) - }) - - request -} - -let setupDatabase = (~dbName, ~objectStoreName, ~onSuccess, ~onError) => { - let request = openDBAndGetRequest(~dbName, ~objectStoreName) - - request->OpenDBRequest.onsuccess(event => { - let db = getDbFromEvent(event) - onSuccess(db) - }) - - request->OpenDBRequest.onerror(event => { - let errorMessage = getErrorMessageFromEvent(event) - onError(errorMessage) - }) + switch IndexedDB.getIndexedDBInstance() { + | Some(idb) => + let request = idb->IndexedDB.open_(dbName, 1) + + request->OpenDBRequest.onupgradeneeded(event => { + let db = getDbFromEvent(event) + let _ = + db->DB.createObjectStore(objectStoreName, {"keyPath": "timestamp", "autoIncrement": true}) + }) + + Some(request) + | None => None + } } let addData = (db, objectStoreName, data) => { diff --git a/src/service-worker/ServiceWorker.res b/src/service-worker/ServiceWorker.res new file mode 100644 index 000000000..1c832cc2f --- /dev/null +++ b/src/service-worker/ServiceWorker.res @@ -0,0 +1,70 @@ +@val @scope("self.clients") external claim: unit => unit = "claim" +@val @scope("self") external skipWaiting: unit => unit = "skipWaiting" +@val @scope("self") +external selfAddEventListener: (string, 'event => unit) => unit = "addEventListener" + +let sendLogs = async (logs: array) => { + if logs->Array.length > 0 { + try { + let bodyStr = logs->JSON.Encode.array->JSON.stringify + let response = await Fetch.fetch( + GlobalVars.logEndpoint, + { + method: #POST, + body: Fetch.Body.string(bodyStr), + headers: Fetch.Headers.fromObject( + Dict.fromArray([("Content-Type", "application/json")])->Utils.dictToObj, + ), + }, + ) + let _ = response->Fetch.Response.ok + } catch { + | err => Console.error2("[ServiceWorker] Failed to send logs:", err) + } + } +} + +let sendIdbLogs = async () => { + try { + let logs = await LoggerUtils.retrieveAndClearLogsFromIndexedDB() + if logs->Array.length > 0 { + logs->sendLogs->ignore + } + } catch { + | err => Console.error2("[ServiceWorker] Failed to send iDB logs:", err) + } +} + +let processMessage = event => { + try { + let data = event["data"] + let type_ = data->Dict.get("type")->CommonUtils.getStringFromOptionalJson("") + if type_ === "SEND_LOGS" { + sendIdbLogs()->ignore + let newLogs = data->CommonUtils.getArray("logs") + if newLogs->Array.length > 0 { + newLogs->sendLogs->ignore + } + } + } catch { + | err => Console.error2("[ServiceWorker] Error:", err) + } +} + +selfAddEventListener("message", event => processMessage(event)) + +let processActivate = async () => { + try { + let logs = await LoggerUtils.retrieveAndClearLogsFromIndexedDB() + logs->sendLogs->ignore + } catch { + | err => Console.error2("[ServiceWorker] Failed to send logs on activate:", err) + } +} + +selfAddEventListener("install", _ => skipWaiting()) + +selfAddEventListener("activate", _ => { + claim() + processActivate()->ignore +}) diff --git a/src/service-worker/ServiceWorkerHelpers.res b/src/service-worker/ServiceWorkerHelpers.res new file mode 100644 index 000000000..e6edf25b5 --- /dev/null +++ b/src/service-worker/ServiceWorkerHelpers.res @@ -0,0 +1,34 @@ +let getContainer = () => + try { + Window.Navigator.serviceWorker + } catch { + | _ => None + } + +let getController = () => + getContainer()->Option.flatMap(container => + container->Window.ServiceWorkerContainer.controller->Nullable.toOption + ) + +let isAvailable = () => + getController() + ->Option.map(controller => + controller->Window.ServiceWorker.scriptURL->String.includes("hs-sdk-sw.js") + ) + ->Option.getOr(false) + +let registerSW = async () => + switch getContainer() { + | Some(container) => + try { + let _ = await container->Window.ServiceWorkerContainer.register("/hs-sdk-sw.js") + } catch { + | _ => () + } + | None => () + } + +let sendMessage = message => + getController()->Option.forEach(controller => + controller->Window.ServiceWorker.postMessage(message) + ) diff --git a/webpack.common.js b/webpack.common.js index e006ea30f..8cd78765d 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -146,7 +146,7 @@ const visaAPIKeyId = getEnvVariable("VISA_API_KEY_ID", ""); const visaAPICertificatePem = getEnvVariable("VISA_API_CERTIFICATE_PEM", ""); const repoVersion = getEnvVariable( "SDK_TAG_VERSION", - require("./package.json").version + require("./package.json").version, ); /* @@ -235,6 +235,7 @@ module.exports = (publicPath = "auto") => { HyperLoader: "./src/hyper-loader/HyperLoader.bs.js", ClickToPayAuthenticationSession: "./src/hyper-loader/AuthenticationSessionMethods.bs.js", + "hs-sdk-sw": "./src/service-worker/ServiceWorker.bs.js", }; const definePluginValues = { @@ -276,14 +277,14 @@ module.exports = (publicPath = "auto") => { "Content-Security-Policy": { "http-equiv": "Content-Security-Policy", content: `default-src 'self' ; script-src ${authorizedScriptSources.join( - " " + " ", )}; style-src ${authorizedStyleSources.join(" ")}; frame-src ${authorizedFrameSources.join(" ")}; img-src ${authorizedImageSources.join(" ")}; font-src ${authorizedFontSources.join(" ")}; connect-src ${authorizedConnectSources.join( - " " + " ", )} ${logEndpoint} ${backendEndPoint}; `, }, @@ -301,14 +302,14 @@ module.exports = (publicPath = "auto") => { "Content-Security-Policy": { "http-equiv": "Content-Security-Policy", content: `default-src 'self' ; script-src ${authorizedScriptSources.join( - " " + " ", )}; style-src ${authorizedStyleSources.join(" ")}; frame-src ${authorizedFrameSources.join(" ")}; img-src ${authorizedImageSources.join(" ")}; font-src ${authorizedFontSources.join(" ")}; connect-src ${authorizedConnectSources.join( - " " + " ", )} ${logEndpoint} ${backendEndPoint}; `, }, @@ -331,7 +332,7 @@ module.exports = (publicPath = "auto") => { analyzerMode: "static", reportFilename: "bundle-report.html", openAnalyzer: false, - }) + }), ); } @@ -351,7 +352,7 @@ module.exports = (publicPath = "auto") => { paths: ["dist"], }, }, - }) + }), ); }