diff --git a/eslintrc.json b/.eslintrc.json similarity index 100% rename from eslintrc.json rename to .eslintrc.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b0bd31b..ce83733 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,6 +39,3 @@ jobs: GITHUB_TOKEN: ${{ secrets.D2L_RELEASE_TOKEN }} MINOR_RELEASE_WITH_LMS: true NPM: true - - name: Get new git HEAD - id: git - run: echo "head=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT diff --git a/README.md b/README.md index a2a27a1..c39bd9e 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,104 @@ Install from CodeArtifact: npm install @d2l/lms-context-provider ``` +## Usage + +### Using a Client + +#### Requesting Data + +To request data, import `tryGet` from the client and invoke it directly. The first argument should be a context type corresponding to a registered host plugin, while the second argument is an optional set of options to pass through to the host plugin. The third argument is an optional callback to allow a consumer to subscribe to future changes to the data they're requesting. + +```js +import { tryGet } from '@d2l/lms-context-provider/client.js'; + +const val = await tryGet( + 'my-context-type', + { someProp: someVal }, + (changedValues) => { + // This callback should accept a single argument: + // an object containing any relevant information from the host plugin + if (changedValues.someChangedProp === 'someVal') { + doSomeWork(changedValues.someChangedProp); + } + } +); +doSomeWork(val); +``` + +If no host plugin is registered to handle a request, or if the data being requested isn't available, the host will return `undefined`. The host plugin may also need to rely on asynchronous methods to return data, so client code should be resilient to receiving a promise that doesn't resolve or takes some time to resolve. + +If no host has been initialized, `tryGet` will reject with an error. + +#### Performing an Action + +To initiate an action on the host but doesn't require return data, import `tryPerform` from the client and invoke it directly. The first argument should be a context type corresponding to a registered host plugin, while the second argument is an optional set of options to pass through to the host plugin. It is not possible to subscribe to change events using this function. + +```js +import { tryPerform } from '@d2l/lms-context-provider/client.js;' + +await tryPerform('my-context-type', { someProp: someVal }); +``` + +If no host plugin is registered to handle a request, or if the data being requested isn't available, this promise will immediately resolve and nothing will happen. As with the `tryGet` function, the host plugin may need to perform asynchronous actions to fulfill a request, so this promise may also never resolve, or may take some time to resolve. + +If no host has been initialized, `tryPerform` will reject with an error. + +### Configuring a Host + +#### Initializing + +Initializing a host should rarely be necessary. Within a Brightspace instance, this will generally be handled by BSI via our MVC and legacy frameworks. To initialize a host, import and execute the `initialize` function. + +```js +import { initialize } from '@d2l/lms-context-provider/host.js'; + +initialize(); +``` + +#### Registering Plugins + +To register a host plugin, import and execute the `registerPlugin` function on a page where a host has already been initialized. The provided context type should be unique per page. If a plugin needs to return data to a client, it should provide a `tryGetCallback` as the second argument. If clients can be notified when the data changes, then it should provide a `subscribeCallback` as the third argument. + +```js +import { registerPlugin } from '@d2l/lms-context/provider/host.js'; + +function tryGetCallback(options) { + // This can be asynchronous. + const returnVal = doSomeWork(options); + return returnVal; +} + +function subscribeCallback(onChange, options) { + // this can be asynchronous. + const returnVal = doSomeWork(options); + + // Options are defined by the host, not the plugin. sendImmediate indicates the change handler should be invoked immediately. + if (options.sendImmediate) { + // Expects an object as its only argument. + onChange({ val: returnVal }); + } + + // onChange event should be subscribed to future changes. + registerOnChangeEvent(onChange); +} + +registerPlugin('my-context-type', tryGetCallback, subscribeCallback); +``` + +#### Framed Clients + +When working with a client inside an iframe, the host page needs to explicitly allow that iframe. To do this, import and execute `allowFrame` from the host page (the host must already be initialized.). The first argument must be the iframe element itself. The second argument should be the expected origin. Requests from clients within iframes that have not explicitly been allowed or that come from a different origin will be rejected. + +```js +import { allowFrame } from '@d2l/lms-context-provider/host.js'; + +const myFrame = document.createElement('iframe'); +document.body.append(myFrame); + +allowFrame(myFrame, window.location.origin); +``` + ## Developing, Testing and Contributing After cloning the repo, run `npm install` to install dependencies. diff --git a/package-lock.json b/package-lock.json index 80c3c12..2d4b5ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,9 +74,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.24.1.tgz", - "integrity": "sha512-Pc65opHDliVpRHuKfzI+gSA4zcgr65O4cl64fFJIWEEh8JoHIHh0Oez1Eo8Arz8zq/JhgKodQaxEwUPRtZylVA==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.24.4.tgz", + "integrity": "sha512-vg8Gih2MLK+kOkHJp4gBEIkyaIi00jgWot2D9QOmmfLC8jINSOzmCLta6Bvz/JSBCqnegV0L80jhxkol5GWNfQ==", "dev": true, "peer": true, "engines": { @@ -84,19 +84,19 @@ } }, "node_modules/@babel/core": { - "version": "7.24.3", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.3.tgz", - "integrity": "sha512-5FcvN1JHw2sHJChotgx8Ek0lyuh4kCKelgMTTqhYJJtloNvUfpAFMeNQUtdlIaktwrSV9LtCdqwk48wL2wBacQ==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.4.tgz", + "integrity": "sha512-MBVlMXP+kkl5394RBLSxxk/iLTeVGuXTV3cIDXavPpMMqnSnt6apKgan/U8O3USWZCWZT/TbgfEpKa4uMgN4Dg==", "dev": true, "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.24.2", - "@babel/generator": "^7.24.1", + "@babel/generator": "^7.24.4", "@babel/helper-compilation-targets": "^7.23.6", "@babel/helper-module-transforms": "^7.23.3", - "@babel/helpers": "^7.24.1", - "@babel/parser": "^7.24.1", + "@babel/helpers": "^7.24.4", + "@babel/parser": "^7.24.4", "@babel/template": "^7.24.0", "@babel/traverse": "^7.24.1", "@babel/types": "^7.24.0", @@ -134,9 +134,9 @@ } }, "node_modules/@babel/generator": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.1.tgz", - "integrity": "sha512-DfCRfZsBcrPEHUfuBMgbJ1Ut01Y/itOs+hY2nFLgqsqXd52/iSiVq5TITtUasIUgm+IIKdY2/1I7auiQOEeC9A==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.4.tgz", + "integrity": "sha512-Xd6+v6SnjWVx/nus+y0l1sxMOTOMBkyL4+BIdbALyatQnAe/SRVjANeDPSCYaX+i1iJmuGSKf3Z+E+V/va1Hvw==", "dev": true, "peer": true, "dependencies": { @@ -292,9 +292,9 @@ } }, "node_modules/@babel/helpers": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.1.tgz", - "integrity": "sha512-BpU09QqEe6ZCHuIHFphEFgvNSrubve1FtyMton26ekZ85gRGi6LrTF7zArARp2YvyFxloeiRmtSCq5sjh1WqIg==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.4.tgz", + "integrity": "sha512-FewdlZbSiwaVGlgT1DPANDuCHaDMiOo+D/IDYRFYjHOuv66xMSJ7fQwwODwRNAPkADIO/z1EoF/l2BCWlWABDw==", "dev": true, "peer": true, "dependencies": { @@ -322,9 +322,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.1.tgz", - "integrity": "sha512-Zo9c7N3xdOIQrNip7Lc9wvRPzlRtovHVE4lkz8WEDr7uYh/GMQhSiIgFxGIArRHYdJE5kxtZjAf8rT0xhdLCzg==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.4.tgz", + "integrity": "sha512-zTvEBcghmeBma9QIGunWevvBAp4/Qu9Bdq+2k0Ot4fVMD6v3dsC9WOcRSKk7tRRyBM/53yKMJko9xOatGQAwSg==", "dev": true, "peer": true, "bin": { @@ -746,9 +746,9 @@ } }, "node_modules/@open-wc/scoped-elements/node_modules/lit": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/lit/-/lit-3.1.2.tgz", - "integrity": "sha512-VZx5iAyMtX7CV4K8iTLdCkMaYZ7ipjJZ0JcSdJ0zIdGxxyurjIn7yuuSxNBD7QmjvcNJwr0JS4cAdAtsy7gZ6w==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lit/-/lit-3.1.3.tgz", + "integrity": "sha512-l4slfspEsnCcHVRTvaP7YnkTZEZggNFywLEIhQaGhYDczG+tu/vlgm/KaWIEjIp+ZyV20r2JnZctMb8LeLCG7Q==", "dev": true, "dependencies": { "@lit/reactive-element": "^2.0.4", @@ -757,9 +757,9 @@ } }, "node_modules/@open-wc/scoped-elements/node_modules/lit-element": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.0.4.tgz", - "integrity": "sha512-98CvgulX6eCPs6TyAIQoJZBCQPo80rgXR+dVBs61cstJXqtI+USQZAbA4gFHh6L/mxBx9MrgPLHLsUgDUHAcCQ==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.0.5.tgz", + "integrity": "sha512-iTWskWZEtn9SyEf4aBG6rKT8GABZMrTWop1+jopsEOgEcugcXJGKuX5bEbkq9qfzY+XB4MAgCaSPwnNpdsNQ3Q==", "dev": true, "dependencies": { "@lit-labs/ssr-dom-shim": "^1.2.0", @@ -792,9 +792,9 @@ } }, "node_modules/@open-wc/testing-helpers": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@open-wc/testing-helpers/-/testing-helpers-3.0.0.tgz", - "integrity": "sha512-zkR39b7ljH/TqZgzBB9ekHKg1OLvR/JQYCEaW76V0RuASfV/vkgx2xfUQNe8DlEOLOetRZ3agFqssEREF45ClA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@open-wc/testing-helpers/-/testing-helpers-3.0.1.tgz", + "integrity": "sha512-hyNysSatbgT2FNxHJsS3rGKcLEo6+HwDFu1UQL6jcSQUabp/tj3PyX7UnXL3H5YGv0lJArdYLSnvjLnjn3O2fw==", "dev": true, "dependencies": { "@open-wc/scoped-elements": "^3.0.2", @@ -813,9 +813,9 @@ } }, "node_modules/@puppeteer/browsers": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.2.0.tgz", - "integrity": "sha512-MC7LxpcBtdfTbzwARXIkqGZ1Osn3nnZJlm+i0+VqHl72t//Xwl9wICrXT8BwtgC6s1xJNHsxOpvzISUqe92+sw==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.2.3.tgz", + "integrity": "sha512-bJ0UBsk0ESOs6RFcLXOt99a3yTDcOKlzfjad+rhFwdaG1Lu/Wzq58GHYCDTlZ9z6mldf4g+NTb+TXEfe0PpnsQ==", "dev": true, "dependencies": { "debug": "4.3.4", @@ -915,9 +915,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.13.2.tgz", - "integrity": "sha512-3XFIDKWMFZrMnao1mJhnOT1h2g0169Os848NhhmGweEcfJ4rCi+3yMCOLG4zA61rbJdkcrM/DjVZm9Hg5p5w7g==", + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.16.4.tgz", + "integrity": "sha512-GkhjAaQ8oUTOKE4g4gsZ0u8K/IHU1+2WQSgS1TwTcYvL+sjbaQjNHFXbOJ6kgqGHIO1DfUhI/Sphi9GkRT9K+Q==", "cpu": [ "arm" ], @@ -928,9 +928,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.13.2.tgz", - "integrity": "sha512-GdxxXbAuM7Y/YQM9/TwwP+L0omeE/lJAR1J+olu36c3LqqZEBdsIWeQ91KBe6nxwOnb06Xh7JS2U5ooWU5/LgQ==", + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.16.4.tgz", + "integrity": "sha512-Bvm6D+NPbGMQOcxvS1zUl8H7DWlywSXsphAeOnVeiZLQ+0J6Is8T7SrjGTH29KtYkiY9vld8ZnpV3G2EPbom+w==", "cpu": [ "arm64" ], @@ -941,9 +941,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.13.2.tgz", - "integrity": "sha512-mCMlpzlBgOTdaFs83I4XRr8wNPveJiJX1RLfv4hggyIVhfB5mJfN4P8Z6yKh+oE4Luz+qq1P3kVdWrCKcMYrrA==", + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.16.4.tgz", + "integrity": "sha512-i5d64MlnYBO9EkCOGe5vPR/EeDwjnKOGGdd7zKFhU5y8haKhQZTN2DgVtpODDMxUr4t2K90wTUJg7ilgND6bXw==", "cpu": [ "arm64" ], @@ -954,9 +954,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.13.2.tgz", - "integrity": "sha512-yUoEvnH0FBef/NbB1u6d3HNGyruAKnN74LrPAfDQL3O32e3k3OSfLrPgSJmgb3PJrBZWfPyt6m4ZhAFa2nZp2A==", + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.16.4.tgz", + "integrity": "sha512-WZupV1+CdUYehaZqjaFTClJI72fjJEgTXdf4NbW69I9XyvdmztUExBtcI2yIIU6hJtYvtwS6pkTkHJz+k08mAQ==", "cpu": [ "x64" ], @@ -967,9 +967,22 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.13.2.tgz", - "integrity": "sha512-GYbLs5ErswU/Xs7aGXqzc3RrdEjKdmoCrgzhJWyFL0r5fL3qd1NPcDKDowDnmcoSiGJeU68/Vy+OMUluRxPiLQ==", + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.16.4.tgz", + "integrity": "sha512-ADm/xt86JUnmAfA9mBqFcRp//RVRt1ohGOYF6yL+IFCYqOBNwy5lbEK05xTsEoJq+/tJzg8ICUtS82WinJRuIw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.16.4.tgz", + "integrity": "sha512-tJfJaXPiFAG+Jn3cutp7mCs1ePltuAgRqdDZrzb1aeE3TktWWJ+g7xK9SNlaSUFw6IU4QgOxAY4rA+wZUT5Wfg==", "cpu": [ "arm" ], @@ -980,9 +993,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.13.2.tgz", - "integrity": "sha512-L1+D8/wqGnKQIlh4Zre9i4R4b4noxzH5DDciyahX4oOz62CphY7WDWqJoQ66zNR4oScLNOqQJfNSIAe/6TPUmQ==", + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.16.4.tgz", + "integrity": "sha512-7dy1BzQkgYlUTapDTvK997cgi0Orh5Iu7JlZVBy1MBURk7/HSbHkzRnXZa19ozy+wwD8/SlpJnOOckuNZtJR9w==", "cpu": [ "arm64" ], @@ -993,9 +1006,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.13.2.tgz", - "integrity": "sha512-tK5eoKFkXdz6vjfkSTCupUzCo40xueTOiOO6PeEIadlNBkadH1wNOH8ILCPIl8by/Gmb5AGAeQOFeLev7iZDOA==", + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.16.4.tgz", + "integrity": "sha512-zsFwdUw5XLD1gQe0aoU2HVceI6NEW7q7m05wA46eUAyrkeNYExObfRFQcvA6zw8lfRc5BHtan3tBpo+kqEOxmg==", "cpu": [ "arm64" ], @@ -1006,11 +1019,11 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.13.2.tgz", - "integrity": "sha512-zvXvAUGGEYi6tYhcDmb9wlOckVbuD+7z3mzInCSTACJ4DQrdSLPNUeDIcAQW39M3q6PDquqLWu7pnO39uSMRzQ==", + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.16.4.tgz", + "integrity": "sha512-p8C3NnxXooRdNrdv6dBmRTddEapfESEUflpICDNKXpHvTjRRq1J82CbU5G3XfebIZyI3B0s074JHMWD36qOW6w==", "cpu": [ - "ppc64le" + "ppc64" ], "dev": true, "optional": true, @@ -1019,9 +1032,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.13.2.tgz", - "integrity": "sha512-C3GSKvMtdudHCN5HdmAMSRYR2kkhgdOfye4w0xzyii7lebVr4riCgmM6lRiSCnJn2w1Xz7ZZzHKuLrjx5620kw==", + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.16.4.tgz", + "integrity": "sha512-Lh/8ckoar4s4Id2foY7jNgitTOUQczwMWNYi+Mjt0eQ9LKhr6sK477REqQkmy8YHY3Ca3A2JJVdXnfb3Rrwkng==", "cpu": [ "riscv64" ], @@ -1032,9 +1045,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.13.2.tgz", - "integrity": "sha512-l4U0KDFwzD36j7HdfJ5/TveEQ1fUTjFFQP5qIt9gBqBgu1G8/kCaq5Ok05kd5TG9F8Lltf3MoYsUMw3rNlJ0Yg==", + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.16.4.tgz", + "integrity": "sha512-1xwwn9ZCQYuqGmulGsTZoKrrn0z2fAur2ujE60QgyDpHmBbXbxLaQiEvzJWDrscRq43c8DnuHx3QorhMTZgisQ==", "cpu": [ "s390x" ], @@ -1045,9 +1058,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.13.2.tgz", - "integrity": "sha512-xXMLUAMzrtsvh3cZ448vbXqlUa7ZL8z0MwHp63K2IIID2+DeP5iWIT6g1SN7hg1VxPzqx0xZdiDM9l4n9LRU1A==", + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.16.4.tgz", + "integrity": "sha512-LuOGGKAJ7dfRtxVnO1i3qWc6N9sh0Em/8aZ3CezixSTM+E9Oq3OvTsvC4sm6wWjzpsIlOCnZjdluINKESflJLA==", "cpu": [ "x64" ], @@ -1058,9 +1071,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.13.2.tgz", - "integrity": "sha512-M/JYAWickafUijWPai4ehrjzVPKRCyDb1SLuO+ZyPfoXgeCEAlgPkNXewFZx0zcnoIe3ay4UjXIMdXQXOZXWqA==", + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.16.4.tgz", + "integrity": "sha512-ch86i7KkJKkLybDP2AtySFTRi5fM3KXp0PnHocHuJMdZwu7BuyIKi35BE9guMlmTpwwBTB3ljHj9IQXnTCD0vA==", "cpu": [ "x64" ], @@ -1071,9 +1084,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.13.2.tgz", - "integrity": "sha512-2YWwoVg9KRkIKaXSh0mz3NmfurpmYoBBTAXA9qt7VXk0Xy12PoOP40EFuau+ajgALbbhi4uTj3tSG3tVseCjuA==", + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.16.4.tgz", + "integrity": "sha512-Ma4PwyLfOWZWayfEsNQzTDBVW8PZ6TUUN1uFTBQbF2Chv/+sjenE86lpiEwj2FiviSmSZ4Ap4MaAfl1ciF4aSA==", "cpu": [ "arm64" ], @@ -1084,9 +1097,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.13.2.tgz", - "integrity": "sha512-2FSsE9aQ6OWD20E498NYKEQLneShWes0NGMPQwxWOdws35qQXH+FplabOSP5zEe1pVjurSDOGEVCE2agFwSEsw==", + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.16.4.tgz", + "integrity": "sha512-9m/ZDrQsdo/c06uOlP3W9G2ENRVzgzbSXmXHT4hwVaDQhYcRpi9bgBT0FTG9OhESxwK0WjQxYOSfv40cU+T69w==", "cpu": [ "ia32" ], @@ -1097,9 +1110,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.13.2.tgz", - "integrity": "sha512-7h7J2nokcdPePdKykd8wtc8QqqkqxIrUz7MHj6aNr8waBRU//NLDVnNjQnqQO6fqtjrtCdftpbTuOKAyrAQETQ==", + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.16.4.tgz", + "integrity": "sha512-YunpoOAyGLDseanENHmbFvQSfVL5BxW3k7hhy0eN4rb3gS/ct75dVD0EXOWIqFT/nE8XYW6LP6vz6ctKRi0k9A==", "cpu": [ "x64" ], @@ -1273,9 +1286,9 @@ } }, "node_modules/@types/express-serve-static-core": { - "version": "4.17.43", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.43.tgz", - "integrity": "sha512-oaYtiBirUOPQGSWNGPWnzyAFJ0BP3cwvN4oWZQY+zUBwpVIGsKUkpBpSztp74drYcjavs7SKFZ4DX1V2QeN8rg==", + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.0.tgz", + "integrity": "sha512-bGyep3JqPCRry1wq+O5n7oiBgGWmeIJXPjXXCo8EK0u8duZGSYar7cGqd3ML2JUsLGeB7fmc06KYo9fLGWqPvQ==", "dev": true, "dependencies": { "@types/node": "*", @@ -1365,9 +1378,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.12.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.2.tgz", - "integrity": "sha512-zQ0NYO87hyN6Xrclcqp7f8ZbXNbRfoGWNcMvHTPQp9UUrwI0mI7XBz+cu7/W6/VClYo2g63B0cjull/srU7LgQ==", + "version": "20.12.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz", + "integrity": "sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==", "dev": true, "dependencies": { "undici-types": "~5.26.4" @@ -1380,9 +1393,9 @@ "dev": true }, "node_modules/@types/qs": { - "version": "6.9.14", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.14.tgz", - "integrity": "sha512-5khscbd3SwWMhFqylJBLQ0zIu7c1K6Vz0uBIt915BI3zV0q1nfjRQD3RqSBcPaO6PHEF4ov/t9y89fSiyThlPA==", + "version": "6.9.15", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.15.tgz", + "integrity": "sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==", "dev": true }, "node_modules/@types/range-parser": { @@ -1408,14 +1421,14 @@ } }, "node_modules/@types/serve-static": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.5.tgz", - "integrity": "sha512-PDRk21MnK70hja/YF8AHfC7yIsiQHn1rcXx7ijCFBX/k+XQJhQT/gw3xekXKJvx+5SXaMMS8oqQy09Mzvz2TuQ==", + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", + "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", "dev": true, "dependencies": { "@types/http-errors": "*", - "@types/mime": "*", - "@types/node": "*" + "@types/node": "*", + "@types/send": "*" } }, "node_modules/@types/sinon": { @@ -1496,9 +1509,9 @@ } }, "node_modules/@web/dev-server": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@web/dev-server/-/dev-server-0.4.3.tgz", - "integrity": "sha512-vf2ZVjdTj8ExrMSYagyHD+snRue9oRetynxd1p0P7ndEpZDKeNLYsvkJyo0pNU6moBxHmXnYeC5VrAT4E3+lNg==", + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@web/dev-server/-/dev-server-0.4.4.tgz", + "integrity": "sha512-Gye0DhDbst/KVNRCFzRd+4V9LJmuuQYJBsf6UXeEbCYuBSKeshEW4AA1esLsfy1gONsD6NIGiru5509l35P9Ug==", "dev": true, "dependencies": { "@babel/code-frame": "^7.12.11", @@ -2096,35 +2109,44 @@ "optional": true }, "node_modules/bare-fs": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-2.2.2.tgz", - "integrity": "sha512-X9IqgvyB0/VA5OZJyb5ZstoN62AzD7YxVGog13kkfYWYqJYcK0kcqLZ6TrmH5qr4/8//ejVcX4x/a0UvaogXmA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-2.3.0.tgz", + "integrity": "sha512-TNFqa1B4N99pds2a5NYHR15o0ZpdNKbAeKTE/+G6ED/UeOavv8RY3dr/Fu99HW3zU3pXpo2kDNO8Sjsm2esfOw==", "dev": true, "optional": true, "dependencies": { "bare-events": "^2.0.0", - "bare-os": "^2.0.0", "bare-path": "^2.0.0", - "streamx": "^2.13.0" + "bare-stream": "^1.0.0" } }, "node_modules/bare-os": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-2.2.1.tgz", - "integrity": "sha512-OwPyHgBBMkhC29Hl3O4/YfxW9n7mdTr2+SsO29XBWKKJsbgj3mnorDB80r5TiCQgQstgE5ga1qNYrpes6NvX2w==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-2.3.0.tgz", + "integrity": "sha512-oPb8oMM1xZbhRQBngTgpcQ5gXw6kjOaRsSWsIeNyRxGed2w/ARyP7ScBYpWR1qfX2E5rS3gBw6OWcSQo+s+kUg==", "dev": true, "optional": true }, "node_modules/bare-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-2.1.0.tgz", - "integrity": "sha512-DIIg7ts8bdRKwJRJrUMy/PICEaQZaPGZ26lsSx9MJSwIhSrcdHn7/C8W+XmnG/rKi6BaRcz+JO00CjZteybDtw==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-2.1.2.tgz", + "integrity": "sha512-o7KSt4prEphWUHa3QUwCxUI00R86VdjiuxmJK0iNVDHYPGo+HsDaVCnqCmPbf/MiW1ok8F4p3m8RTHlWk8K2ig==", "dev": true, "optional": true, "dependencies": { "bare-os": "^2.1.0" } }, + "node_modules/bare-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-1.0.0.tgz", + "integrity": "sha512-KhNUoDL40iP4gFaLSsoGE479t0jHijfYdIcxRn/XtezA2BaUD0NRf/JGRpsMq6dMNM+SrCrB0YSSo/5wBY4rOQ==", + "dev": true, + "optional": true, + "dependencies": { + "streamx": "^2.16.1" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -2345,9 +2367,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001605", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001605.tgz", - "integrity": "sha512-nXwGlFWo34uliI9z3n6Qc0wZaf7zaZWA1CPZ169La5mV3I/gem7bst0vr5XQH5TJXZIMfDeZyOrZnSlVzKxxHQ==", + "version": "1.0.30001612", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001612.tgz", + "integrity": "sha512-lFgnZ07UhaCcsSZgWW0K5j4e69dK1u/ltrL9lTUiFOwNHs12S3UMIEYgBV0Z6C6hRDev7iRnMzzYmKabYdXF9g==", "dev": true, "funding": [ { @@ -2528,9 +2550,9 @@ } }, "node_modules/chromium-bidi": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.5.16.tgz", - "integrity": "sha512-IT5lnR44h/qZQ4GaCHvBxYIl4cQL2i9UvFyYeRyVdcpY04hx5H720HQfe/7Oz7ndxaYVLQFGpCO71J4X2Ye/Gw==", + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.5.19.tgz", + "integrity": "sha512-UA6zL77b7RYCjJkZBsZ0wlvCTD+jTjllZ8f6wdO4buevXgTZYjV+XLB9CiEa2OuuTGGTLnI7eN9I60YxuALGQg==", "dev": true, "dependencies": { "mitt": "3.0.1", @@ -3033,9 +3055,9 @@ } }, "node_modules/devtools-protocol": { - "version": "0.0.1262051", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1262051.tgz", - "integrity": "sha512-YJe4CT5SA8on3Spa+UDtNhEqtuV6Epwz3OZ4HQVLhlRccpZ9/PAYk0/cy/oKxFKRrZPBUPyxympQci4yWNWZ9g==", + "version": "0.0.1273771", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1273771.tgz", + "integrity": "sha512-QDbb27xcTVReQQW/GHJsdQqGKwYBE7re7gxehj467kKP2DKuYBUj6i2k5LRiAC66J1yZG/9gsxooz/s9pcm0Og==", "dev": true }, "node_modules/diff": { @@ -3153,9 +3175,9 @@ "dev": true }, "node_modules/electron-to-chromium": { - "version": "1.4.723", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.723.tgz", - "integrity": "sha512-rxFVtrMGMFROr4qqU6n95rUi9IlfIm+lIAt+hOToy/9r6CDv0XiEcQdC3VP71y1pE5CFTzKV0RvxOGYCPWWHPw==", + "version": "1.4.749", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.749.tgz", + "integrity": "sha512-LRMMrM9ITOvue0PoBrvNIraVmuDbJV5QC9ierz/z5VilMdPOVMjOtpICNld3PuXuTZ3CHH/UPxX9gHhAPwi+0Q==", "dev": true, "peer": true }, @@ -5294,9 +5316,9 @@ } }, "node_modules/koa": { - "version": "2.15.2", - "resolved": "https://registry.npmjs.org/koa/-/koa-2.15.2.tgz", - "integrity": "sha512-MXTeZH3M6AJ8ukW2QZ8wqO3Dcdfh2WRRmjCBkEP+NhKNCiqlO5RDqHmSnsyNrbRJrdjyvIGSJho4vQiWgQJSVA==", + "version": "2.15.3", + "resolved": "https://registry.npmjs.org/koa/-/koa-2.15.3.tgz", + "integrity": "sha512-j/8tY9j5t+GVMLeioLaxweJiKUayFhlGqNTzf2ZGwL0ZCQijd2RLHK0SLW5Tsko8YyyqCZC2cojIb0/s62qTAg==", "dev": true, "dependencies": { "accepts": "^1.3.5", @@ -5668,9 +5690,9 @@ } }, "node_modules/lit-html": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.1.2.tgz", - "integrity": "sha512-3OBZSUrPnAHoKJ9AMjRL/m01YJxQMf+TMHanNtTHG68ubjnZxK0RFl102DPzsw4mWnHibfZIBJm3LWCZ/LmMvg==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.1.3.tgz", + "integrity": "sha512-FwIbqDD8O/8lM4vUZ4KvQZjPPNx7V1VhT7vmRB8RBAO0AU6wuTVdoXiu2CivVjEGdugvcbPNBLtPE1y0ifplHA==", "dev": true, "dependencies": { "@types/trusted-types": "^2.0.2" @@ -6065,9 +6087,9 @@ } }, "node_modules/nise/node_modules/path-to-regexp": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz", - "integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.2.tgz", + "integrity": "sha512-GQX3SSMokngb36+whdpRXE+3f9V8UzyAorlYvOGx87ufGHehNTn5lCxrKtLyZ4Yl/wEKnNnr98ZzOwwDZV5ogw==", "dev": true }, "node_modules/no-case": { @@ -6440,9 +6462,9 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", - "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==", + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.1.tgz", + "integrity": "sha512-tS24spDe/zXhWbNPErCHs/AGOzbKGHT+ybSBqmdLm8WZ1xXLWvH8Qn71QPAlqVhd0qUTWjy+Kl9JmISgDdEjsA==", "dev": true, "engines": { "node": "14 || >=16.14" @@ -6512,12 +6534,12 @@ } }, "node_modules/playwright": { - "version": "1.42.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.42.1.tgz", - "integrity": "sha512-PgwB03s2DZBcNRoW+1w9E+VkLBxweib6KTXM0M3tkiT4jVxKSi6PmVJ591J+0u10LUrgxB7dLRbiJqO5s2QPMg==", + "version": "1.43.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.1.tgz", + "integrity": "sha512-V7SoH0ai2kNt1Md9E3Gwas5B9m8KR2GVvwZnAI6Pg0m3sh7UvgiYhRrhsziCmqMJNouPckiOhk8T+9bSAK0VIA==", "dev": true, "dependencies": { - "playwright-core": "1.42.1" + "playwright-core": "1.43.1" }, "bin": { "playwright": "cli.js" @@ -6530,9 +6552,9 @@ } }, "node_modules/playwright-core": { - "version": "1.42.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.42.1.tgz", - "integrity": "sha512-mxz6zclokgrke9p1vtdy/COWBH+eOZgYUVVU34C73M+4j4HLlQJHtfcqiqqxpP0o8HhMkflvfbquLX5dg6wlfA==", + "version": "1.43.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.1.tgz", + "integrity": "sha512-EI36Mto2Vrx6VF7rm708qSnesVQKbxEWvPrfA1IPY6HgczBplDx7ENtx+K2n4kJ41sLLkuGfmb0ZLSSXlDhqPg==", "dev": true, "bin": { "playwright-core": "cli.js" @@ -6681,15 +6703,15 @@ } }, "node_modules/puppeteer-core": { - "version": "22.6.2", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-22.6.2.tgz", - "integrity": "sha512-Sws/9V2/7nFrn3MSsRPHn1pXJMIFn6FWHhoMFMUBXQwVvcBstRIa9yW8sFfxePzb56W1xNfSYzPRnyAd0+qRVQ==", + "version": "22.7.1", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-22.7.1.tgz", + "integrity": "sha512-jD7T7yN7PWGuJmNT0TAEboA26s0VVnvbgCxqgQIF+eNQW2u71ENaV2JwzSJiCHO+e72H4Ue6AgKD9USQ8xAcOQ==", "dev": true, "dependencies": { - "@puppeteer/browsers": "2.2.0", - "chromium-bidi": "0.5.16", + "@puppeteer/browsers": "2.2.3", + "chromium-bidi": "0.5.19", "debug": "4.3.4", - "devtools-protocol": "0.0.1262051", + "devtools-protocol": "0.0.1273771", "ws": "8.16.0" }, "engines": { @@ -6718,9 +6740,9 @@ } }, "node_modules/qs": { - "version": "6.12.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.12.0.tgz", - "integrity": "sha512-trVZiI6RMOkO476zLGaBIzszOdFPnCCXHPG9kn0yuS1uz6xdVxPfZdB3vUig9pxPFDM9BRAgz/YUIVQ1/vuiUg==", + "version": "6.12.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.12.1.tgz", + "integrity": "sha512-zWmv4RSuB9r2mYQw3zxQuHWeU+42aKi1wWig/j4ele4ygELZ7PEO6MM7rim9oAQH2A5MWfsAVf/jPvTPgCbvUQ==", "dev": true, "dependencies": { "side-channel": "^1.0.6" @@ -6997,9 +7019,9 @@ } }, "node_modules/rollup": { - "version": "4.13.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.13.2.tgz", - "integrity": "sha512-MIlLgsdMprDBXC+4hsPgzWUasLO9CE4zOkj/u6j+Z6j5A4zRY+CtiXAdJyPtgCsc42g658Aeh1DlrdVEJhsL2g==", + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.16.4.tgz", + "integrity": "sha512-kuaTJSUbz+Wsb2ATGvEknkI12XV40vIiHmLuFlejoo7HtDok/O5eDDD0UpCVY5bBX5U5RYo8wWP83H7ZsqVEnA==", "dev": true, "dependencies": { "@types/estree": "1.0.5" @@ -7012,21 +7034,22 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.13.2", - "@rollup/rollup-android-arm64": "4.13.2", - "@rollup/rollup-darwin-arm64": "4.13.2", - "@rollup/rollup-darwin-x64": "4.13.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.13.2", - "@rollup/rollup-linux-arm64-gnu": "4.13.2", - "@rollup/rollup-linux-arm64-musl": "4.13.2", - "@rollup/rollup-linux-powerpc64le-gnu": "4.13.2", - "@rollup/rollup-linux-riscv64-gnu": "4.13.2", - "@rollup/rollup-linux-s390x-gnu": "4.13.2", - "@rollup/rollup-linux-x64-gnu": "4.13.2", - "@rollup/rollup-linux-x64-musl": "4.13.2", - "@rollup/rollup-win32-arm64-msvc": "4.13.2", - "@rollup/rollup-win32-ia32-msvc": "4.13.2", - "@rollup/rollup-win32-x64-msvc": "4.13.2", + "@rollup/rollup-android-arm-eabi": "4.16.4", + "@rollup/rollup-android-arm64": "4.16.4", + "@rollup/rollup-darwin-arm64": "4.16.4", + "@rollup/rollup-darwin-x64": "4.16.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.16.4", + "@rollup/rollup-linux-arm-musleabihf": "4.16.4", + "@rollup/rollup-linux-arm64-gnu": "4.16.4", + "@rollup/rollup-linux-arm64-musl": "4.16.4", + "@rollup/rollup-linux-powerpc64le-gnu": "4.16.4", + "@rollup/rollup-linux-riscv64-gnu": "4.16.4", + "@rollup/rollup-linux-s390x-gnu": "4.16.4", + "@rollup/rollup-linux-x64-gnu": "4.16.4", + "@rollup/rollup-linux-x64-musl": "4.16.4", + "@rollup/rollup-win32-arm64-msvc": "4.16.4", + "@rollup/rollup-win32-ia32-msvc": "4.16.4", + "@rollup/rollup-win32-x64-msvc": "4.16.4", "fsevents": "~2.3.2" } }, @@ -7332,9 +7355,9 @@ } }, "node_modules/socks": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.1.tgz", - "integrity": "sha512-B6w7tkwNid7ToxjZ08rQMT8M9BJAf8DKx8Ft4NivzH0zBUfd6jldGcisJn/RLgxcX3FPNDdNQCUEMMT79b+oCQ==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", + "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==", "dev": true, "dependencies": { "ip-address": "^9.0.5", @@ -7677,9 +7700,9 @@ } }, "node_modules/terser": { - "version": "5.30.2", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.30.2.tgz", - "integrity": "sha512-vTDjRKYKip4dOFL5VizdoxHTYDfEXPdz5t+FbxCC5Rp2s+KbEO8w5wqMDPgj7CtFKZuzq7PXv28fZoXfqqBVuw==", + "version": "5.30.4", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.30.4.tgz", + "integrity": "sha512-xRdd0v64a8mFK9bnsKVdoNP9GQIKUAaJPTaqEQDL4w/J8WaW4sWXXoMZ+6SimPkfT5bElreXf8m9HnmPc3E1BQ==", "dev": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", diff --git a/package.json b/package.json index ab3144a..971ab0e 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,14 @@ "type": "module", "version": "1.1.0", "repository": "https://github.com/BrightspaceUI/lms-context-provider.git", + "publishConfig": { + "registry": "https://d2l-569998014834.d.codeartifact.us-east-1.amazonaws.com/npm/private/" + }, "scripts": { "lint": "npm run lint:eslint", "lint:eslint": "eslint . --ext .js,.html", "test": "npm run test:unit", - "test:unit": "d2l-test-runner --files ./src/**/test/*.test.js" + "test:unit": "d2l-test-runner --files ./test/**/*.test.js" }, "author": "D2L Corporation", "license": "Apache-2.0", @@ -18,9 +21,11 @@ "eslint-config-brightspace": "^1", "sinon": "^17" }, + "exports": { + "./host.js": "./src/host/host.js", + "./client.js": "./src/client/client.js" + }, "files": [ - "/src", - "!demo", - "!test" + "/src" ] } diff --git a/src/client/client-internal.js b/src/client/client-internal.js new file mode 100644 index 0000000..7a53b6d --- /dev/null +++ b/src/client/client-internal.js @@ -0,0 +1,146 @@ +import { LmsContextProviderError } from '../error.js'; + +const messageTimeoutMs = 75; +let oneTimeMessageListenerInitialized = false; +let subscriptionListenerInitialized = false; +let framed; + +const oneTimeCallbacks = new Map(); +const subscriptionCallbacks = new Map(); + +function handleOneTimeMessageResponse(e) { + if (!e?.data?.isContextProvider || !e.data.type) return; + + const callbacks = oneTimeCallbacks.get(e.data.type); + if (callbacks === undefined || callbacks.length === 0) return; + + callbacks.forEach(cb => cb(e.data.value)); + oneTimeCallbacks.set(e.data.type, []); +} + +async function sendMessage(message) { + if (!oneTimeMessageListenerInitialized) { + window.addEventListener('message', handleOneTimeMessageResponse); + oneTimeMessageListenerInitialized = true; + } + + return await new Promise(resolve => { + if (!oneTimeCallbacks.has(message.type)) { + oneTimeCallbacks.set(message.type, []); + } + + oneTimeCallbacks.get(message.type).push(resolve); + window.parent.postMessage(message, '*'); + }); +} + +function handleSubscribedChangeResponseEvent(e) { + handleSubscribedChangeResponse(e.detail); +} + +function handleSubscribedChangeResponseMessage(e) { + if (!e?.data?.isContextProvider) return; + handleSubscribedChangeResponse(e.data); +} + +function handleSubscribedChangeResponse(responseData) { + if (!responseData?.changedValues || !responseData.type) return; + const callbacks = subscriptionCallbacks.get(responseData.type); + callbacks.forEach(cb => cb(responseData.changedValues)); +} + +async function sendEvent(type, options, subscribe) { + const isframedVal = await isFramed(); + + if (subscribe && !subscriptionListenerInitialized) { + isframedVal + ? window.addEventListener('message', handleSubscribedChangeResponseMessage) + : document.addEventListener('lms-context-change', handleSubscribedChangeResponseEvent); + + subscriptionListenerInitialized = true; + } + + if (isframedVal) { + const message = { + isContextProvider: true, + type, + options, + subscribe + }; + + return await Promise.race([ + sendMessage(message), + new Promise((_, reject) => + setTimeout( + () => reject(new LmsContextProviderError('No response from host')), + messageTimeoutMs + ) + ) + ]); + } else { + const event = new CustomEvent( + 'lms-context-request', { + detail: { type, options, subscribe } + } + ); + + document.dispatchEvent(event); + return event.detail.handled + ? event.detail.value + : Promise.reject(new LmsContextProviderError('No response from host')); + } +} + +export async function isFramed() { + if (framed !== undefined) return framed; + + try { + if (window === window.parent) { + framed = false; + return framed; + } + } catch (e) { + framed = false; + return framed; + } + + framed = await Promise.race([ + sendMessage({ isContextProvider: true, type: 'framed-request' }), + new Promise(resolve => setTimeout(() => resolve(false), messageTimeoutMs)) + ]); + + return framed; +} + +export async function tryGet(contextType, options, onChangeCallback) { + const subscribe = (typeof(onChangeCallback) === 'function') || false; + + // Send one-time request first to make sure it's responded to before any change listeners are registered. + const value = await sendEvent(contextType, options, subscribe); + + if (subscribe) { + if (!subscriptionCallbacks.has(contextType)) { + subscriptionCallbacks.set(contextType, []); + } + subscriptionCallbacks.get(contextType).push(onChangeCallback); + } + + return value; +} + +export async function tryPerform(actionType, options) { + await sendEvent(actionType, options, false); +} + +export function reset() { + window.removeEventListener('message', handleOneTimeMessageResponse); + window.removeEventListener('message', handleSubscribedChangeResponseMessage); + document.removeEventListener('lms-context-change', handleSubscribedChangeResponseEvent); + + oneTimeMessageListenerInitialized = false; + subscriptionListenerInitialized = false; + framed = undefined; + + oneTimeCallbacks.clear(); + subscriptionCallbacks.clear(); +} diff --git a/src/client/client.js b/src/client/client.js new file mode 100644 index 0000000..92279e3 --- /dev/null +++ b/src/client/client.js @@ -0,0 +1,9 @@ +import { tryGet as tryGetImpl, tryPerform as tryPerformImpl } from './client-internal.js'; + +export async function tryGet(contextType, options, onChangeCallback) { + return await tryGetImpl(contextType, options, onChangeCallback); +} + +export async function tryPerform(actionType, options) { + await tryPerformImpl(actionType, options); +} diff --git a/src/error.js b/src/error.js new file mode 100644 index 0000000..f6114d5 --- /dev/null +++ b/src/error.js @@ -0,0 +1,6 @@ +export class LmsContextProviderError extends Error { + constructor(message) { + super(`lms-context-provider: ${message}`); + this.name = 'LmsContextProviderError'; + } +} diff --git a/src/host/host-internal.js b/src/host/host-internal.js new file mode 100644 index 0000000..2c18d92 --- /dev/null +++ b/src/host/host-internal.js @@ -0,0 +1,121 @@ +import { LmsContextProviderError } from '../error.js'; + +let initialized = false; + +const allowedFrames = new Map(); +const registeredPlugins = new Map(); +const subscriptionQueue = new Set(); + +function handleContextRequest(type, options, subscribe) { + const plugin = registeredPlugins.get(type); + + if (subscribe && !subscriptionQueue.has(type)) { + subscriptionQueue.add(type); + if (plugin && plugin.subscribe) plugin.subscribe(changedValues => sendChangeEvent(type, changedValues)); + } + + return plugin && plugin.tryGet && plugin.tryGet(options); +} + +function handleContextRequestEvent(e) { + if (!e.detail || !e.detail.type) return; + e.detail.value = handleContextRequest(e.detail.type, e.detail.options, e.detail.subscribe); + e.detail.handled = true; +} + +function handleContextRequestMessage(e) { + if (!e.data.isContextProvider) return; + if (!e.data.type || !/^(?:http|https):\/\//.test(e.origin)) { + throw new LmsContextProviderError(`Invalid message sent by framed client at origin ${e.origin}`); + } + + let targetFrame; + for (const frame of allowedFrames.keys()) { + if (frame.contentWindow === e.source) { + targetFrame = frame; + break; + } + } + + if (!targetFrame || allowedFrames.get(targetFrame) !== e.origin) return; + + const messageType = e.data.type; + if (messageType === 'framed-request') { + targetFrame.contentWindow.postMessage({ isContextProvider: true, type: 'framed-request', value: true }, e.origin); + return; + } + + const value = handleContextRequest(messageType, e.data.options, e.data.subscribe); + targetFrame.contentWindow.postMessage({ isContextProvider: true, type: messageType, value: value }, e.origin); +} + +function sendChangeEvent(type, changedValues) { + // Dispatch document-level change event for any non-framed consumers. + document.dispatchEvent(new CustomEvent( + 'lms-context-change', { + detail: { + type: type, + changedValues: changedValues + } + } + )); + + // Dispatch postMessages to registered frames + allowedFrames.forEach((origin, frame) => { + frame.contentWindow.postMessage({ isContextProvider: true, type: type, changedValues: changedValues }, origin); + }); +} + +export function initialize() { + if (initialized) return; + + window.addEventListener('message', handleContextRequestMessage); + document.addEventListener('lms-context-request', handleContextRequestEvent); + + initialized = true; +} + +export function allowFrame(frame, origin) { + if (!initialized) { + throw new LmsContextProviderError(`Can't register frame with id ${frame.id}. Context provider host has not been initialized.`); + } + + if (allowedFrames.has(frame)) { + throw new LmsContextProviderError(`A frame with id ${frame.id} has already been registered with this host.`); + } + + allowedFrames.set(frame, origin); +} + +export function registerPlugin(type, tryGetCallback, subscriptionCallback) { + if (!initialized) { + throw new LmsContextProviderError(`Can't register plugin with type ${type}. Context provider host has not been initialized.`); + } + + if (registeredPlugins.has(type)) { + throw new LmsContextProviderError(`A plugin with type ${type} has already been registered with this host.`); + } + + registeredPlugins.set(type, { + tryGet: tryGetCallback, + subscribe: subscriptionCallback + }); + + // Process any existing subscription requests + if (subscriptionQueue.has(type) && subscriptionCallback) { + subscriptionCallback(changedValues => sendChangeEvent(type, changedValues), { sendImmediate: true }); + } +} + +export function reset() { + if (!initialized) return; + + window.removeEventListener('message', handleContextRequestMessage); + document.removeEventListener('lms-context-request', handleContextRequestEvent); + + allowedFrames.clear(); + registeredPlugins.clear(); + subscriptionQueue.clear(); + + initialized = false; +} diff --git a/src/host/host.js b/src/host/host.js new file mode 100644 index 0000000..ceb0b80 --- /dev/null +++ b/src/host/host.js @@ -0,0 +1,13 @@ +import { allowFrame as allowFrameImpl, initialize as initializeImpl, registerPlugin as registerPluginImpl } from './host-internal.js'; + +export function initialize() { + initializeImpl(); +} + +export function allowFrame(frame, origin) { + allowFrameImpl(frame, origin); +} + +export function registerPlugin(name, tryGetCallback, subscriptionCallback) { + registerPluginImpl(name, tryGetCallback, subscriptionCallback); +} diff --git a/test/client.test.js b/test/client.test.js new file mode 100644 index 0000000..74684c8 --- /dev/null +++ b/test/client.test.js @@ -0,0 +1,558 @@ +import { aTimeout, expect, fixture, html } from '@brightspace-ui/testing'; +import { isFramed, reset, tryGet, tryPerform } from '../src/client/client-internal.js'; +import { restore, spy, stub, useFakeTimers } from 'sinon'; +import { LmsContextProviderError } from '../src/error.js'; + +const mockContextType = 'test-context'; +const mockOpts = { test: 'test' }; + +function assertFramedOneTimeRequestMessage(messageData, type, opts, subscribe) { + expect(messageData.isContextProvider).to.be.true; + expect(messageData.type).to.equal(type); + expect(messageData.options).to.deep.equal(opts); + expect(messageData.subscribe).to.equal(subscribe); +} + +describe('lms-context-provider client', () => { + + let clock; + beforeEach(() => { + clock = useFakeTimers({ + now: Date.now(), + shouldAdvanceTime: true + }); + }); + + afterEach(() => { + reset(); + clock.restore(); + restore(); + }); + + describe('is framed', () => { + + const setUpIsFramedMessageListener = (frame, spy, respond) => { + const handleIsFramedMessage = e => { + frame.contentWindow.removeEventListener('message', handleIsFramedMessage); + if (spy) spy(e.data); + if (!respond) return; + + window.postMessage({ isContextProvider: true, type: 'framed-request', value: true }, '*'); + }; + + frame.contentWindow.addEventListener('message', handleIsFramedMessage); + }; + + let mockFrame; + beforeEach(async() => { + mockFrame = await fixture(html``); + }); + + it('is not framed if the window is its own parent', async() => { + stub(window, 'parent').value(window); + const listenerSpy = spy(); + setUpIsFramedMessageListener(mockFrame, listenerSpy, true); + + const framed = await isFramed(); + expect(framed).to.be.false; + expect(listenerSpy).not.to.have.been.called; + }); + + it('is not framed if accessing the window parent throws', async() => { + stub(window, 'parent').throws(); + const listenerSpy = spy(); + setUpIsFramedMessageListener(mockFrame, listenerSpy, true); + + const framed = await isFramed(); + expect(framed).to.be.false; + expect(listenerSpy).not.to.have.been.called; + }); + + it('is not framed if the host does not respond to an is-framed request', async() => { + stub(window, 'parent').value(mockFrame.contentWindow); + const listenerSpy = spy(); + setUpIsFramedMessageListener(mockFrame, listenerSpy, false); + + const framed = isFramed(); + await clock.tickAsync(75); + + expect(await framed).to.be.false; + expect(listenerSpy).to.have.been.calledOnce; + }); + + it('is framed if the host responds to an is-framed request', async() => { + stub(window, 'parent').value(mockFrame.contentWindow); + const listenerSpy = spy(); + setUpIsFramedMessageListener(mockFrame, listenerSpy, true); + + const framed = await isFramed(); + expect(framed).to.be.true; + expect(listenerSpy).to.have.been.calledOnce; + + // Validate message params + expect(listenerSpy.args[0]).to.have.length(1); + const messageData = listenerSpy.args[0][0]; + assertFramedOneTimeRequestMessage(messageData, 'framed-request', undefined, undefined); + }); + + }); + + describe('framed client', () => { + + const sendResponseMessage = (isContextProvider, returnVal, type) => { + window.postMessage({ isContextProvider: isContextProvider, type: type, options: mockOpts, value: returnVal }, '*'); + }; + + const sendSubscriptionChangeMessage = (isContextProvider, type, changedValues) => { + window.postMessage({ isContextProvider: isContextProvider, type: type, changedValues: changedValues }, '*'); + }; + + const setUpMockHostMessageListener = (frame, spy, respond, returnVal, isContextProvider, omitType) => { + isContextProvider = isContextProvider ?? true; + + const handleMessage = e => { + // Shortcut past framed-requests, as we're testing isFramed separately + if (e.data.type === 'framed-request') { + window.postMessage({ isContextProvider: true, type: 'framed-request', value: true }, '*'); + return; + } + + frame.contentWindow.removeEventListener('message', handleMessage); + if (spy) spy(e.data); + if (!respond) return; + + sendResponseMessage(isContextProvider, returnVal, omitType ? undefined : mockContextType); + }; + + frame.contentWindow.addEventListener('message', handleMessage); + }; + + let mockFrame; + beforeEach(async() => { + mockFrame = await fixture(html``); + stub(window, 'parent').value(mockFrame.contentWindow); + }); + + describe('tryGet', () => { + + it('returns requested data when provided by the host', async() => { + const testVal = 'testVal'; + const requestSpy = spy(); + setUpMockHostMessageListener(mockFrame, requestSpy, true, testVal); + + const val = await tryGet(mockContextType, mockOpts); + expect(val).to.equal(testVal); + + expect(requestSpy).to.have.been.calledOnce; + expect(requestSpy.args[0]).to.have.length(1); + + const messageData = requestSpy.args[0][0]; + assertFramedOneTimeRequestMessage(messageData, mockContextType, mockOpts, false); + }); + + it('returns correct requested data when provided by the host on subsequent calls', async() => { + const firstTestVal = 'testVal'; + const secondTestVal = 'otherTestVal'; + + setUpMockHostMessageListener(mockFrame, undefined, true, firstTestVal); + await tryGet(mockContextType, mockOpts); + + const requestSpy = spy(); + setUpMockHostMessageListener(mockFrame, requestSpy, true, secondTestVal); + + const secondVal = await tryGet(mockContextType, mockOpts); + expect(secondVal).to.equal(secondTestVal); + + expect(requestSpy).to.have.been.calledOnce; + expect(requestSpy.args[0]).to.have.length(1); + + const messageData = requestSpy.args[0][0]; + assertFramedOneTimeRequestMessage(messageData, mockContextType, mockOpts, false); + }); + + it('rejects when the host does not respond', async() => { + const requestSpy = spy(); + setUpMockHostMessageListener(mockFrame, requestSpy, false); + + const val = tryGet(mockContextType, mockOpts); + return val.then(val => { + expect.fail(`Should reject, but ${val} was returned`); + }, err => { + expect(err).to.be.an.instanceof(LmsContextProviderError); + + expect(requestSpy).to.have.been.calledOnce; + expect(requestSpy.args[0]).to.have.length(1); + + const messageData = requestSpy.args[0][0]; + assertFramedOneTimeRequestMessage(messageData, mockContextType, mockOpts, false); + }); + }); + + it('rejects if isContextProvider is not provided in message', async() => { + const testVal = 'testVal'; + const requestSpy = spy(); + setUpMockHostMessageListener(mockFrame, requestSpy, true, testVal, false); + + const val = tryGet(mockContextType, mockOpts); + return val.then(val => { + expect.fail(`Should reject, but ${val} was returned`); + }, err => { + expect(err).to.be.an.instanceof(LmsContextProviderError); + + expect(requestSpy).to.have.been.calledOnce; + expect(requestSpy.args[0]).to.have.length(1); + + const messageData = requestSpy.args[0][0]; + assertFramedOneTimeRequestMessage(messageData, mockContextType, mockOpts, false); + }); + }); + + it('rejects if type is not provided in message', async() => { + const testVal = 'testVal'; + const requestSpy = spy(); + setUpMockHostMessageListener(mockFrame, requestSpy, true, testVal, true, true); + + const val = tryGet(mockContextType, mockOpts); + return val.then(val => { + expect.fail(`Should reject, but ${val} was returned`); + }, err => { + expect(err).to.be.an.instanceof(LmsContextProviderError); + + expect(requestSpy).to.have.been.calledOnce; + expect(requestSpy.args[0]).to.have.length(1); + + const messageData = requestSpy.args[0][0]; + assertFramedOneTimeRequestMessage(messageData, mockContextType, mockOpts, false); + }); + }); + + it('does not send subscribe event if onChange callback is not a function', async() => { + const requestSpy = spy(); + setUpMockHostMessageListener(mockFrame, requestSpy, true); + + await tryGet(mockContextType, mockOpts, 'notAFunction'); + + expect(requestSpy).to.have.been.calledOnce; + expect(requestSpy.args[0]).to.have.length(1); + + const messageData = requestSpy.args[0][0]; + assertFramedOneTimeRequestMessage(messageData, mockContextType, mockOpts, false); + }); + + it('executes onChange callback when valid subscription change message is received', async() => { + const testValues = { + testVal: 'testVal' + }; + + const requestSpy = spy(); + setUpMockHostMessageListener(mockFrame, requestSpy, true); + + const subscriptionSpy = spy(); + // Request a value with an onChange callback to set up subscription + await tryGet(mockContextType, mockOpts, subscriptionSpy); + + // Validate subscription info sent with request + expect(requestSpy).to.have.been.calledOnce; + expect(requestSpy.args[0]).to.have.length(1); + + const messageData = requestSpy.args[0][0]; + assertFramedOneTimeRequestMessage(messageData, mockContextType, mockOpts, true); + + sendSubscriptionChangeMessage(true, mockContextType, testValues); + await aTimeout(50); + + expect(subscriptionSpy).to.have.been.calledOnce; + expect(subscriptionSpy.args[0]).to.have.length(1); + + // Validate values provided to callback + const changedValues = subscriptionSpy.args[0][0]; + expect(changedValues).to.deep.equal(testValues); + }); + + it('does not execute onChange callback when isContextProvider is missing from subscription change message', async() => { + const testValues = { + testVal: 'testVal' + }; + + setUpMockHostMessageListener(mockFrame, undefined, true); + + const subscriptionSpy = spy(); + // Request a value with an onChange callback to set up subscription + await tryGet(mockContextType, mockOpts, subscriptionSpy); + + sendSubscriptionChangeMessage(false, mockContextType, testValues); + await aTimeout(50); + + expect(subscriptionSpy).not.to.have.been.called; + }); + + it('does not execute onChange callback when type is missing from subscription change message', async() => { + const testValues = { + testVal: 'testVal' + }; + + setUpMockHostMessageListener(mockFrame, undefined, true); + + const subscriptionSpy = spy(); + // Request a value with an onChange callback to set up subscription + await tryGet(mockContextType, mockOpts, subscriptionSpy); + + sendSubscriptionChangeMessage(true, undefined, testValues); + await aTimeout(50); + + expect(subscriptionSpy).not.to.have.been.called; + }); + + it('does not execute onChange callback when changed values are missing from subscription change message', async() => { + setUpMockHostMessageListener(mockFrame, undefined, true); + + const subscriptionSpy = spy(); + // Request a value with an onChange callback to set up subscription + await tryGet(mockContextType, mockOpts, subscriptionSpy); + + sendSubscriptionChangeMessage(true, mockContextType, undefined); + await aTimeout(50); + + expect(subscriptionSpy).not.to.have.been.called; + }); + + }); + + describe('tryPerform', () => { + + it('does not provide a return value if the host response includes one', async() => { + const testVal = 'testVal'; + const requestSpy = spy(); + setUpMockHostMessageListener(mockFrame, requestSpy, true, testVal); + + const val = await tryPerform(mockContextType, mockOpts); + expect(val).to.equal(undefined); + + expect(requestSpy).to.have.been.calledOnce; + expect(requestSpy.args[0]).to.have.length(1); + + const messageData = requestSpy.args[0][0]; + assertFramedOneTimeRequestMessage(messageData, mockContextType, mockOpts, false); + }); + + it('rejects if the host does not respond', async() => { + const requestSpy = spy(); + setUpMockHostMessageListener(mockFrame, requestSpy, false); + + return tryPerform(mockContextType, mockOpts).then(() => { + expect.fail('Should reject, but did not'); + }, err => { + expect(err).to.be.an.instanceof(LmsContextProviderError); + + expect(requestSpy).to.have.been.calledOnce; + expect(requestSpy.args[0]).to.have.length(1); + + const messageData = requestSpy.args[0][0]; + assertFramedOneTimeRequestMessage(messageData, mockContextType, mockOpts, false); + }); + }); + + it('rejects if isContextProvider is not provided in message', async() => { + const requestSpy = spy(); + setUpMockHostMessageListener(mockFrame, requestSpy, true, undefined, false); + + return tryPerform(mockContextType, mockOpts).then(() => { + expect.fail('Should reject, but did not'); + }, err => { + expect(err).to.be.an.instanceof(LmsContextProviderError); + + expect(requestSpy).to.have.been.calledOnce; + expect(requestSpy.args[0]).to.have.length(1); + + const messageData = requestSpy.args[0][0]; + assertFramedOneTimeRequestMessage(messageData, mockContextType, mockOpts, false); + }); + }); + + it('rejects if type is not provided in message', async() => { + const requestSpy = spy(); + setUpMockHostMessageListener(mockFrame, requestSpy, true, undefined, true, true); + + return tryPerform(mockContextType, mockOpts).then(() => { + expect.fail('Should reject, but did not'); + }, err => { + expect(err).to.be.an.instanceof(LmsContextProviderError); + + expect(requestSpy).to.have.been.calledOnce; + expect(requestSpy.args[0]).to.have.length(1); + + const messageData = requestSpy.args[0][0]; + assertFramedOneTimeRequestMessage(messageData, mockContextType, mockOpts, false); + }); + }); + + }); + + }); + + describe('unframed client', () => { + + const sendSubscriptionChangeEvent = (type, changedValues) => { + document.dispatchEvent(new CustomEvent( + 'lms-context-change', { + detail: { + type: type, + changedValues: changedValues + } + } + )); + }; + + const setUpMockHostEventListener = (spy, handled, returnVal) => { + const handleContextRequest = e => { + document.removeEventListener('lms-context-request', handleContextRequest); + if (spy) spy(e.detail); + e.detail.handled = handled; + e.detail.value = returnVal; + }; + + document.addEventListener('lms-context-request', handleContextRequest); + }; + + const assertOneTimeRequestEvent = (eventDetails, type, opts, subscribe) => { + expect(eventDetails.type).to.equal(type); + expect(eventDetails.options).to.deep.equal(opts); + expect(eventDetails.subscribe).to.equal(subscribe); + }; + + describe('tryGet', () => { + + it('returns requested data when provided by the host', async() => { + const testVal = 'testVal'; + const requestSpy = spy(); + setUpMockHostEventListener(requestSpy, true, testVal); + + const val = await tryGet(mockContextType, mockOpts); + expect(val).to.equal(testVal); + + expect(requestSpy).to.have.been.calledOnce; + expect(requestSpy.args[0]).to.have.length(1); + + const eventDetails = requestSpy.args[0][0]; + assertOneTimeRequestEvent(eventDetails, mockContextType, mockOpts, false); + }); + + it('returns correct requested data when provided by the host on subsequent calls', async() => { + const firstTestVal = 'testVal'; + const secondTestVal = 'otherTestVal'; + + setUpMockHostEventListener(undefined, true, firstTestVal); + await tryGet(mockContextType, mockOpts); + + const requestSpy = spy(); + setUpMockHostEventListener(requestSpy, true, secondTestVal); + + const secondVal = await tryGet(mockContextType, mockOpts); + expect(secondVal).to.equal(secondTestVal); + + expect(requestSpy).to.have.been.calledOnce; + expect(requestSpy.args[0]).to.have.length(1); + + const eventDetails = requestSpy.args[0][0]; + assertOneTimeRequestEvent(eventDetails, mockContextType, mockOpts, false); + }); + + it('does not send subscribe event if onChange callback is not a function', async() => { + const requestSpy = spy(); + setUpMockHostEventListener(requestSpy, true, undefined); + + await tryGet(mockContextType, mockOpts, 'notAFunction'); + + expect(requestSpy).to.have.been.calledOnce; + expect(requestSpy.args[0]).to.have.length(1); + + const eventDetails = requestSpy.args[0][0]; + assertOneTimeRequestEvent(eventDetails, mockContextType, mockOpts, false); + }); + + it('executes onChange callback when valid subscription change event is received', async() => { + const testValues = { + testVal: 'testVal' + }; + + const requestSpy = spy(); + setUpMockHostEventListener(requestSpy, true, undefined); + + const subscriptionSpy = spy(); + // Request a value with an onChange callback to set up subscription + await tryGet(mockContextType, mockOpts, subscriptionSpy); + + // Validate subscription info sent with request + expect(requestSpy).to.have.been.calledOnce; + expect(requestSpy.args[0]).to.have.length(1); + + const eventDetails = requestSpy.args[0][0]; + assertOneTimeRequestEvent(eventDetails, mockContextType, mockOpts, true); + + sendSubscriptionChangeEvent(mockContextType, testValues); + + expect(subscriptionSpy).to.have.been.calledOnce; + expect(subscriptionSpy.args[0]).to.have.length(1); + + // Validate values provided to callback + const changedValues = subscriptionSpy.args[0][0]; + expect(changedValues).to.deep.equal(testValues); + }); + + it('does not execute onChange callback when type is missing from subscription change event', async() => { + const testValues = { + testVal: 'testVal' + }; + + setUpMockHostEventListener(undefined, true, 'junk'); + + const subscriptionSpy = spy(); + // Request a value with an onChange callback to set up subscription + await tryGet(mockContextType, mockOpts, subscriptionSpy); + + sendSubscriptionChangeEvent(undefined, testValues); + + expect(subscriptionSpy).not.to.have.been.called; + }); + + }); + + describe('tryPerform', () => { + + it('does not provide a return value if the host response includes one', async() => { + const testVal = 'testVal'; + const requestSpy = spy(); + setUpMockHostEventListener(requestSpy, true, testVal); + + const val = await tryPerform(mockContextType, mockOpts); + expect(val).to.equal(undefined); + + expect(requestSpy).to.have.been.calledOnce; + expect(requestSpy.args[0]).to.have.length(1); + + const eventDetails = requestSpy.args[0][0]; + assertOneTimeRequestEvent(eventDetails, mockContextType, mockOpts, false); + }); + + it('rejects if the host does not respond', async() => { + const requestSpy = spy(); + setUpMockHostEventListener(requestSpy, false); + + return tryPerform(mockContextType, mockOpts).then(() => { + expect.fail('Should reject, but did not'); + }, err => { + expect(err).to.be.an.instanceof(LmsContextProviderError); + + expect(requestSpy).to.have.been.calledOnce; + expect(requestSpy.args[0]).to.have.length(1); + + const eventDetails = requestSpy.args[0][0]; + assertOneTimeRequestEvent(eventDetails, mockContextType, mockOpts, false); + }); + }); + + }); + + }); + +}); diff --git a/test/error.test.js b/test/error.test.js new file mode 100644 index 0000000..14fcf68 --- /dev/null +++ b/test/error.test.js @@ -0,0 +1,27 @@ +import { expect, } from '@brightspace-ui/testing'; +import { LmsContextProviderError } from '../src/error.js'; + +describe('lms-context-provider error', () => { + + it('sets a custom name', () => { + const err = new LmsContextProviderError(); + expect(err.name).to.equal('LmsContextProviderError'); + }); + + it('extends the generic Error class', () => { + const err = new LmsContextProviderError(); + expect(err).to.be.an.instanceof(Error); + }); + + it('pre-pends an identifier to the beginning of the provided error message', () => { + const message = 'message'; + const err = new LmsContextProviderError(message); + + const errParts = err.message.split(' '); + expect(errParts).to.have.length(2); + + expect(errParts[0]).to.equal('lms-context-provider:'); + expect(errParts[1]).to.equal(message); + }); + +}); diff --git a/test/host.test.js b/test/host.test.js new file mode 100644 index 0000000..7737535 --- /dev/null +++ b/test/host.test.js @@ -0,0 +1,382 @@ +import { allowFrame, initialize, registerPlugin, reset } from '../src/host/host-internal.js'; +import { aTimeout, expect, fixture, html } from '@brightspace-ui/testing'; +import { restore, spy, stub } from 'sinon'; +import { LmsContextProviderError } from '../src/error.js'; + +const eventListenerType = 'lms-context-request'; +const messageListenerType = 'message'; + +const mockContextType = 'test-context'; +const otherMockContextType = 'other-test-context'; +const mockOpts = { test: 'test' }; + +describe('lms-context-provider host', () => { + + afterEach(() => { + reset(); + restore(); + }); + + describe('initialization', () => { + + it('sets up appropriate event handlers when initialized', () => { + const docSpy = spy(document, 'addEventListener'); + const windowSpy = spy(window, 'addEventListener'); + + initialize(); + + expect(docSpy).to.have.been.calledOnce; + expect(docSpy).to.have.always.been.calledWithMatch(eventListenerType); + expect(docSpy.args[0]).to.have.length(2); + expect(docSpy.args[0][1]).to.be.a('function'); + + expect(windowSpy).to.have.been.calledOnce; + expect(windowSpy).to.have.always.been.calledWithMatch(messageListenerType); + expect(windowSpy.args[0]).to.have.length(2); + expect(windowSpy.args[0][1]).to.be.a('function'); + + }); + + it('does not throw on multiple initializations', () => { + initialize(); + expect(() => initialize()).not.to.throw; + }); + + }); + + describe('allowing frames', () => { + const frame = fixture(html``); + + it('throws when allowing a frame before initialization', () => { + expect(() => allowFrame(frame)).to.throw(LmsContextProviderError); + }); + + it('throws when attempting to allow a frame that has already been allowed', () => { + initialize(); + + allowFrame(frame); + expect(() => allowFrame(frame)).to.throw(LmsContextProviderError); + }); + + }); + + describe('registering plugins', () => { + + it('throws when host has not yet been initialized', () => { + expect(() => registerPlugin(mockContextType)).to.throw(LmsContextProviderError); + }); + + it('throws when trying to re-register an existing plugin', () => { + initialize(); + registerPlugin(mockContextType); + expect(() => registerPlugin(mockContextType)).to.throw(LmsContextProviderError); + }); + + it('does not throw when registering multiple different plugins', () => { + initialize(); + registerPlugin(mockContextType); + expect(() => registerPlugin(otherMockContextType)).not.to.throw(LmsContextProviderError); + }); + + }); + + describe('framed client', () => { + + const sendFramedClientRequest = async(frame, isContextProvider, type, subscriptionMessageSpy) => { + const message = { + isContextProvider: isContextProvider, + type: type, + options: mockOpts, + subscribe: !!subscriptionMessageSpy + }; + + return await new Promise(resolve => { + frame.contentWindow.addEventListener('message', e => { + if (subscriptionMessageSpy) frame.contentWindow.addEventListener('message', subscriptionMessageSpy, { once: true }); + resolve(e.data); + }, { once: true }); + + const script = frame.contentWindow.document.createElement('script'); + script.type = 'text/javascript'; + script.innerHTML = `window.parent.postMessage(${JSON.stringify(message)}, '*');`; + frame.contentWindow.document.head.appendChild(script); + }); + }; + + const assertContextRequestMessageResponse = (messageData, expectedReturnVal, expectedType) => { + expect(messageData.isContextProvider).to.be.true; + expect(messageData.type).to.equal(expectedType || mockContextType); + expect(messageData.value).to.equal(expectedReturnVal); + }; + + const assertSubscriptionMessageResponse = (messageData, expectedReturnVal) => { + expect(messageData.isContextProvider).to.be.true; + expect(messageData.type).to.equal(mockContextType); + expect(messageData.changedValues).to.deep.equal(expectedReturnVal); + }; + + let mockFrame; + beforeEach(async() => { + initialize(); + mockFrame = await fixture(html``); + }); + + it('passes data through when a plugin can handle the request', async() => { + const testVal = 'testVal'; + const tryGetStub = stub().returns(testVal); + registerPlugin(mockContextType, tryGetStub); + allowFrame(mockFrame, window.location.origin); + + const messageData = await sendFramedClientRequest(mockFrame, true, mockContextType); + assertContextRequestMessageResponse(messageData, testVal); + }); + + it('returns undefined when no host plugin can handle the request', async() => { + allowFrame(mockFrame, window.location.origin); + const messageData = await sendFramedClientRequest(mockFrame, true, mockContextType); + assertContextRequestMessageResponse(messageData, undefined); + }); + + it('returns is-framed response when requested, regardless of registered plugins', async() => { + allowFrame(mockFrame, window.location.origin); + + const messageData = await sendFramedClientRequest(mockFrame, true, 'framed-request'); + assertContextRequestMessageResponse(messageData, true, 'framed-request'); + }); + + it('ignores requests without isContextProvider specified', async() => { + const testVal = 'testVal'; + const tryGetStub = stub().returns(testVal); + registerPlugin(mockContextType, tryGetStub); + allowFrame(mockFrame, window.location.origin); + + const messageData = await Promise.race([ + sendFramedClientRequest(mockFrame, false, mockContextType), + aTimeout(50) + ]); + expect(messageData).to.be.undefined; + }); + + it('ignores requests from framed clients that have not been allowed', async() => { + const messageData = await Promise.race([ + sendFramedClientRequest(mockFrame, true, mockContextType), + aTimeout(50) + ]); + expect(messageData).to.be.undefined; + }); + + it('ignores requests from framed clients with an unexpected origin', async() => { + allowFrame(mockFrame, 'someFakeOrigin'); + const messageData = await Promise.race([ + sendFramedClientRequest(mockFrame, true, mockContextType), + aTimeout(50) + ]); + expect(messageData).to.be.undefined; + }); + + it('sends subscription events when a client has requested a subscription', async() => { + const testValues = { + testVal: 'testVal', + otherTestVal: 'otherTestVal' + }; + + const subscriptionSpy = spy(); + registerPlugin(mockContextType, undefined, subscriptionSpy); + allowFrame(mockFrame, window.location.origin); + + const subscriptionMessageSpy = spy(); + await sendFramedClientRequest(mockFrame, true, mockContextType, subscriptionMessageSpy); + expect(subscriptionSpy).to.have.been.calledOnce; + expect(subscriptionSpy.args[0]).to.have.length(1); + + // Trigger subscription callback in order to mimic a context change event + subscriptionSpy.args[0][0](testValues); + await aTimeout(50); + + expect(subscriptionMessageSpy).to.have.been.calledOnce; + expect(subscriptionMessageSpy.args[0]).to.have.length(1); + + // Assert subscription message response + const messageData = subscriptionMessageSpy.args[0][0].data; + assertSubscriptionMessageResponse(messageData, testValues); + }); + + it('sends an immediate subscription event when a plugin is registered and a subscription is queued', async() => { + const testValues = { + testVal: 'testVal', + otherTestVal: 'otherTestVal' + }; + allowFrame(mockFrame, window.location.origin); + + const subscriptionMessageSpy = spy(); + await sendFramedClientRequest(mockFrame, true, mockContextType, subscriptionMessageSpy); + + // Shouldn't receive a subscription message before a host plugin has been registered. + expect(subscriptionMessageSpy).not.to.have.been.called; + + // Register a plugin to handle this context request after it has originally been set. + const subscriptionSpy = spy(); + registerPlugin(mockContextType, undefined, subscriptionSpy); + + expect(subscriptionSpy).to.have.been.calledOnce; + expect(subscriptionSpy.args[0]).to.have.length(2); + expect(subscriptionSpy.args[0][1]).to.deep.equal({ sendImmediate: true }); + + subscriptionSpy.args[0][0](testValues); + await aTimeout(50); + + expect(subscriptionMessageSpy).to.have.been.calledOnce; + expect(subscriptionMessageSpy.args[0]).to.have.length(1); + + // Assert subscription message response + const messageData = subscriptionMessageSpy.args[0][0].data; + assertSubscriptionMessageResponse(messageData, testValues); + }); + + it('does not send subscription events to framed clients that have not been allowed', async() => { + const subscriptionSpy = spy(); + registerPlugin(mockContextType, undefined, subscriptionSpy); + + const subscriptionMessageSpy = spy(); + const messageData = await Promise.race([ + sendFramedClientRequest(mockFrame, true, mockContextType, subscriptionMessageSpy), + aTimeout(50) + ]); + + expect(messageData).to.be.undefined; + expect(subscriptionSpy).not.to.have.been.called; + expect(subscriptionMessageSpy).not.to.have.been.called; + }); + + it('does not send subscription events to framed clients with an unexpected origin', async() => { + const subscriptionSpy = spy(); + registerPlugin(mockContextType, undefined, subscriptionSpy); + allowFrame(mockFrame, 'someFakeOrigin'); + + const subscriptionMessageSpy = spy(); + const messageData = await Promise.race([ + sendFramedClientRequest(mockFrame, true, mockContextType, subscriptionMessageSpy), + aTimeout(50) + ]); + + expect(messageData).to.be.undefined; + expect(subscriptionSpy).not.to.have.been.called; + expect(subscriptionMessageSpy).not.to.have.been.called; + }); + + }); + + describe('unframed client', () => { + + const sendNonFramedClientRequest = (type, subscriptionEventSpy) => { + const eventDetails = { + type: type, + options: mockOpts, + subscribe: !!subscriptionEventSpy + }; + + if (subscriptionEventSpy) { + document.addEventListener('lms-context-change', e => subscriptionEventSpy(e.detail), { once: true }); + } + + const event = new CustomEvent( + 'lms-context-request', { + detail: eventDetails + } + ); + + document.dispatchEvent(event); + return { + handled: event.detail.handled, + value: event.detail.value + }; + }; + + beforeEach(() => { + initialize(); + }); + + it('passes data through when a plugin can handle the request', () => { + const testVal = 'testVal'; + const tryGetStub = stub().returns(testVal); + registerPlugin(mockContextType, tryGetStub); + + const { handled, value } = sendNonFramedClientRequest(mockContextType); + expect(handled).to.be.true; + expect(value).to.equal(testVal); + }); + + it('returns undefined when no host plugin can handle the request', () => { + const { handled, value } = sendNonFramedClientRequest(mockContextType); + expect(handled).to.be.true; + expect(value).to.be.undefined; + }); + + it('ignores requests without a type specified', async() => { + const testVal = 'testVal'; + const tryGetStub = stub().returns(testVal); + registerPlugin(mockContextType, tryGetStub); + + const { handled, value } = sendNonFramedClientRequest(); + expect(handled).to.be.undefined; + expect(value).to.be.undefined; + }); + + it('sends subscription events when a client has requested a subscription', () => { + const testValues = { + testVal: 'testVal', + otherTestVal: 'otherTestVal' + }; + + const subscriptionSpy = spy(); + registerPlugin(mockContextType, undefined, subscriptionSpy); + + const subscriptionEventSpy = spy(); + sendNonFramedClientRequest(mockContextType, subscriptionEventSpy); + expect(subscriptionSpy).to.have.been.calledOnce; + expect(subscriptionSpy.args[0]).to.have.length(1); + + // Trigger subscription callback in order to mimic a context change event + subscriptionSpy.args[0][0](testValues); + + expect(subscriptionEventSpy).to.have.been.calledOnce; + expect(subscriptionEventSpy.args[0]).to.have.length(1); + + // Assert subscription event response + const changedValues = subscriptionEventSpy.args[0][0].changedValues; + expect(changedValues).to.deep.equal(testValues); + }); + + it('sends an immediate subscription event when a plugin is registered and a subscription is queued', () => { + const testValues = { + testVal: 'testVal', + otherTestVal: 'otherTestVal' + }; + + const subscriptionEventSpy = spy(); + sendNonFramedClientRequest(mockContextType, subscriptionEventSpy); + + // Shouldn't receive a subscription event before a host plugin has been registered. + expect(subscriptionEventSpy).not.to.have.been.called; + + // Register a plugin to handle this context request after it has originally been set. + const subscriptionSpy = spy(); + registerPlugin(mockContextType, undefined, subscriptionSpy); + + expect(subscriptionSpy).to.have.been.calledOnce; + expect(subscriptionSpy.args[0]).to.have.length(2); + expect(subscriptionSpy.args[0][1]).to.deep.equal({ sendImmediate: true }); + + subscriptionSpy.args[0][0](testValues); + + expect(subscriptionEventSpy).to.have.been.calledOnce; + expect(subscriptionEventSpy.args[0]).to.have.length(1); + + // Assert subscription event response + const changedValues = subscriptionEventSpy.args[0][0].changedValues; + expect(changedValues).to.deep.equal(testValues); + }); + + }); + +});