From 326801a824c4ea77ec0ee39ac6b4acdb4605c50c Mon Sep 17 00:00:00 2001 From: Kenneth Kalmer Date: Wed, 17 Jun 2026 17:05:26 +0100 Subject: [PATCH 1/2] chore: bump @ably/ui from 17.14.0 to 18.3.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catches docs up to the @ably/ui line shipped on the main marketing site and Voltaire. The headline reason for moving now is the Mixpanel EU-ingestion default that landed in 18.3.1: Mixpanel sunsets its US→EU forwarding bridge on 2026-07-01, and 18.3.1 defaults `mixpanel.init` to `api_host: https://api-eu.mixpanel.com` so we route directly to the EU project instead of relying on the soon-to-be-removed forwarder. This crosses a major boundary (17 → 18), so the design system surface this app consumes (typography utilities, layout primitives, nav/footer components, palette names) should be smoke-checked against the docs site's rendered output before this lands. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 +- yarn.lock | 62 ++++++++++++++++++++++++++++------------------------ 2 files changed, 35 insertions(+), 29 deletions(-) diff --git a/package.json b/package.json index 20287c8f0f..28ad3cae98 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "validate-llms-txt": "node bin/validate-llms.txt.ts" }, "dependencies": { - "@ably/ui": "17.14.0", + "@ably/ui": "18.3.1", "@codesandbox/sandpack-react": "^2.20.0", "@codesandbox/sandpack-themes": "^2.0.21", "@gfx/zopfli": "^1.0.15", diff --git a/yarn.lock b/yarn.lock index acf0862fb2..6439661b79 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,10 +2,10 @@ # yarn lockfile v1 -"@ably/ui@17.14.0": - version "17.14.0" - resolved "https://registry.yarnpkg.com/@ably/ui/-/ui-17.14.0.tgz#a63d3f6e7e778ac753d47d461ea036c8b17c9f26" - integrity sha512-/Q3VMeb2sJ9AxttDOnQf5w+BKbntkaJoMd7rhlBwSPF9yM3KSbs+/DZdz53bMXm0sh+GCgouJfYAJ+xdsTS7ZQ== +"@ably/ui@18.3.1": + version "18.3.1" + resolved "https://registry.yarnpkg.com/@ably/ui/-/ui-18.3.1.tgz#cc15029251a7f9d9611142f0caec7bc206f62cdc" + integrity sha512-GPzmKfu6L2nxubR/MP0yMUPsGuOURekw71g5K+0wP7fA7vJWrjBJydJewIhw9qkXR2vh/fTUIfXcdRdy7h/qzg== dependencies: "@heroicons/react" "^2.2.0" "@radix-ui/react-accordion" "^1.2.1" @@ -17,20 +17,19 @@ "@radix-ui/react-tooltip" "^1.2.8" array-flat-polyfill "^1.0.1" clsx "^2.1.1" - dompurify "^3.2.4" + dompurify "^3.4.9" embla-carousel "^8.6.0" embla-carousel-autoplay "^8.6.0" embla-carousel-react "^8.6.0" - es-toolkit "^1.44.0" + es-toolkit "^1.46.0" highlight.js "^11.11.1" highlightjs-curl "^1.3.0" - js-cookie "^3.0.5" + js-cookie "^3.0.7" react "^18.2.0" react-dom "^18.3.1" - redux "^4.0.5" scroll-lock "^2.1.4" - swr "^2.2.5" - tailwind-merge "^2.5.5" + swr "^2.4.0" + tailwind-merge "^3.6.0" "@adobe/css-tools@^4.4.0": version "4.4.4" @@ -6836,13 +6835,20 @@ domhandler@^5.0.2, domhandler@^5.0.3: dependencies: domelementtype "^2.3.0" -dompurify@*, dompurify@^3.2.4, dompurify@^3.3.1, dompurify@^3.4.0: +dompurify@*, dompurify@^3.3.1, dompurify@^3.4.0: version "3.4.0" resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.0.tgz#b1fc33ebdadb373241621e0a30e4ad81573dfd0b" integrity sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg== optionalDependencies: "@types/trusted-types" "^2.0.7" +dompurify@^3.4.9: + version "3.4.11" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.11.tgz#29c8ba496475f279ef4015784068452fb14a0680" + integrity sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw== + optionalDependencies: + "@types/trusted-types" "^2.0.7" + domutils@^2.5.2, domutils@^2.8.0: version "2.8.0" resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.8.0.tgz#4437def5db6e2d1f5d6ee859bd95ca7d02048135" @@ -7214,10 +7220,10 @@ es-to-primitive@^1.3.0: is-date-object "^1.0.5" is-symbol "^1.0.4" -es-toolkit@^1.44.0: - version "1.44.0" - resolved "https://registry.yarnpkg.com/es-toolkit/-/es-toolkit-1.44.0.tgz#b363b436b6115c3cc9cc21954c1e08ecdaa51c8c" - integrity sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg== +es-toolkit@^1.46.0: + version "1.47.1" + resolved "https://registry.yarnpkg.com/es-toolkit/-/es-toolkit-1.47.1.tgz#6f049fef04c2ef27400e8f38255a0dca323e1c3a" + integrity sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q== es5-ext@^0.10.35, es5-ext@^0.10.46, es5-ext@^0.10.62, es5-ext@^0.10.64, es5-ext@~0.10.14, es5-ext@~0.10.2: version "0.10.64" @@ -10780,10 +10786,10 @@ joi@^17.11.0, joi@^17.9.2: "@sideway/formula" "^3.0.1" "@sideway/pinpoint" "^2.0.0" -js-cookie@^3.0.5: - version "3.0.7" - resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.7.tgz#0a53abfc459c8e89c85d7a38eb6cb68714965b8c" - integrity sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw== +js-cookie@^3.0.7: + version "3.0.8" + resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.8.tgz#444e6f4b27a5d844594fef61c9d6bca5f0787688" + integrity sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw== "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" @@ -13869,7 +13875,7 @@ redux-thunk@^2.4.2: resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.4.2.tgz#b9d05d11994b99f7a91ea223e8b04cf0afa5ef3b" integrity sha512-+P3TjtnP0k/FEjcBL5FZpoovtvrTNT/UXd4/sluaSyrURlSlhLSzEdfsTBW7WsKB6yPvgd7q/iZPICFjW4o57Q== -redux@4.2.1, redux@^4.0.5: +redux@4.2.1: version "4.2.1" resolved "https://registry.yarnpkg.com/redux/-/redux-4.2.1.tgz#c08f4306826c49b5e9dc901dee0452ea8fce6197" integrity sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w== @@ -15179,10 +15185,10 @@ swap-case@^2.0.2: dependencies: tslib "^2.0.3" -swr@^2.2.5: - version "2.4.0" - resolved "https://registry.yarnpkg.com/swr/-/swr-2.4.0.tgz#cd11e368cb13597f61ee3334428aa20b5e81f36e" - integrity sha512-sUlC20T8EOt1pHmDiqueUWMmRRX03W7w5YxovWX7VR2KHEPCTMly85x05vpkP5i6Bu4h44ePSMD9Tc+G2MItFw== +swr@^2.4.0: + version "2.4.1" + resolved "https://registry.yarnpkg.com/swr/-/swr-2.4.1.tgz#c9e48abff6bf4b04846342e2f1f6be108a078cf6" + integrity sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA== dependencies: dequal "^2.0.3" use-sync-external-store "^1.6.0" @@ -15220,10 +15226,10 @@ tagged-tag@^1.0.0: resolved "https://registry.yarnpkg.com/tagged-tag/-/tagged-tag-1.0.0.tgz#a0b5917c2864cba54841495abfa3f6b13edcf4d6" integrity sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng== -tailwind-merge@^2.5.5: - version "2.6.1" - resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-2.6.1.tgz#a9d58240f664d21c33c379a092d9a273f833443b" - integrity sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ== +tailwind-merge@^3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-3.6.0.tgz#88d83242d1dd7bc847223f73dcf210dd1f2ee11c" + integrity sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w== tailwindcss@^3.3.6: version "3.4.19" From 8f978c8ae17975344edcd497c1fe3c769f4fc6cc Mon Sep 17 00:00:00 2001 From: Kenneth Kalmer Date: Wed, 17 Jun 2026 17:20:37 +0100 Subject: [PATCH 2/2] refactor: migrate session/api-key bootstrap off @ably/ui Redux APIs @ably/ui 18 removed the Redux scaffolding that this repo depended on (connectState, selectSessionData, fetchSessionData, getRemoteDataStore, createRemoteDataStore, reducerSessionData, reducerApiKeyData, reducerBlogPosts, reducerFlashes, attachStoreToWindow). Replaced with the Context + SWR pattern @ably/ui now exposes: - SessionDataProvider (SWR-backed) wraps the app at the root and fetches /api/me. UserContextWrapper reads from it via useSessionData and surfaces the result through the existing UserContext as sessionState, so downstream callers don't change. - API-key loading moved to a plain async fetch (src/contexts/user- context/api-keys.ts) driven by useEffect inside UserContextWrapper. This preserves the existing demo-key + real-key flow and the window.ably.docs.DOCS_API_KEY side effect for ad hoc scripts, while dropping the dispatch/reducer indirection. - gatsby-browser drops the createRemoteDataStore / attachStoreToWindow bootstrap; gatsby-ssr keeps the same default-export wiring, which now carries SessionDataProvider in addition to UserContext. - Side-effect import of @ably/ui/core/scripts in GlobalLoading is removed; that import existed to register the Redux store side effects, which no longer exist. - src/redux/ deleted in its entirety; constants live next to the fetcher that uses them. The stale type shim under src/types/ ably-ui-core-scripts is removed. - mocks/handlers.js, user-context.test.tsx point at the new module surface. The msw-blocked test stays skipped. No new dependencies; uses SWR transitively through @ably/ui. Co-Authored-By: Claude Opus 4.7 (1M context) --- gatsby-browser.tsx | 17 --- mocks/handlers.js | 2 +- .../GlobalLoading/GlobalLoading.tsx | 2 - src/contexts/user-context.test.tsx | 47 +++---- src/contexts/user-context.ts | 19 +-- src/contexts/user-context/api-keys.test.ts | 116 +++++++++++++++++ src/contexts/user-context/api-keys.ts | 117 ++++++++++++++++++ .../user-context/wrap-with-provider.tsx | 66 +++++----- src/redux/api-key/api-key-reducer.ts | 28 ----- src/redux/api-key/constants.ts | 6 - src/redux/api-key/index.ts | 2 - src/redux/api-key/remote-api-key-data.ts | 92 -------------- src/redux/fetch-and-add-to-store.ts | 60 --------- src/redux/select-data.ts | 6 - src/types/ably-ui-core-scripts/index.d.ts | 13 -- .../update-ably-connection-keys.test.ts | 8 -- 16 files changed, 289 insertions(+), 312 deletions(-) create mode 100644 src/contexts/user-context/api-keys.test.ts create mode 100644 src/contexts/user-context/api-keys.ts delete mode 100644 src/redux/api-key/api-key-reducer.ts delete mode 100644 src/redux/api-key/constants.ts delete mode 100644 src/redux/api-key/index.ts delete mode 100644 src/redux/api-key/remote-api-key-data.ts delete mode 100644 src/redux/fetch-and-add-to-store.ts delete mode 100644 src/redux/select-data.ts delete mode 100644 src/types/ably-ui-core-scripts/index.d.ts diff --git a/gatsby-browser.tsx b/gatsby-browser.tsx index 2a8da9b155..4b57943ba5 100644 --- a/gatsby-browser.tsx +++ b/gatsby-browser.tsx @@ -1,32 +1,15 @@ import React, { useEffect, useRef } from 'react'; import type { GatsbyBrowser } from 'gatsby'; -import { reducerFlashes } from '@ably/ui/core/Flash'; import { initInsights, trackPageView, setupObserver } from '@ably/ui/core/insights'; -import { - attachStoreToWindow, - createRemoteDataStore, - reducerBlogPosts, - reducerSessionData, -} from '@ably/ui/core/scripts'; import { PostHogProvider } from 'posthog-js/react'; import posthog from 'posthog-js'; -import { reducerApiKeyData } from './src/redux/api-key/api-key-reducer'; import UserContextWrapper from './src/contexts/user-context/wrap-with-provider'; import { useSiteMetadata } from './src/hooks/use-site-metadata'; const onClientEntry: GatsbyBrowser['onClientEntry'] = () => { setupObserver(); - - const store = createRemoteDataStore({ - ...reducerBlogPosts, - ...reducerSessionData, - ...reducerApiKeyData, - ...reducerFlashes, - }); - - attachStoreToWindow(store); }; /** diff --git a/mocks/handlers.js b/mocks/handlers.js index cbe4d90fc3..f4f6337566 100644 --- a/mocks/handlers.js +++ b/mocks/handlers.js @@ -4,7 +4,7 @@ import { WEB_API_USER_DATA_ENDPOINT, WEB_API_KEYS_DATA_ENDPOINT, WEB_API_TEMP_KEY_ENDPOINT, -} from 'src/redux/api-key/constants'; +} from 'src/contexts/user-context/api-keys'; export const handlers = [ http.get(WEB_API_USER_DATA_ENDPOINT, () => { diff --git a/src/components/GlobalLoading/GlobalLoading.tsx b/src/components/GlobalLoading/GlobalLoading.tsx index cb50f78843..058ae93ac3 100644 --- a/src/components/GlobalLoading/GlobalLoading.tsx +++ b/src/components/GlobalLoading/GlobalLoading.tsx @@ -1,8 +1,6 @@ import { FC, ReactNode, useEffect, useContext, useMemo } from 'react'; import { useStaticQuery, graphql } from 'gatsby'; -// Session-related scripts -import '@ably/ui/core/scripts'; import UserContext from '../../contexts/user-context'; import externalScriptInjector from 'src/external-scripts'; diff --git a/src/contexts/user-context.test.tsx b/src/contexts/user-context.test.tsx index c63494fe94..c531440a7f 100644 --- a/src/contexts/user-context.test.tsx +++ b/src/contexts/user-context.test.tsx @@ -1,46 +1,27 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; +import { SessionDataProvider } from '@ably/ui/core/scripts'; + import UserContext from './user-context'; import { UserContextWrapper } from './user-context/wrap-with-provider'; +import { WEB_API_USER_DATA_ENDPOINT } from './user-context/api-keys'; -import { createRemoteDataStore, attachStoreToWindow, reducerSessionData } from '@ably/ui/core/scripts'; - -import { reducerApiKeyData } from 'src/redux/api-key/api-key-reducer'; - -const onClientEntry = () => { - const store = createRemoteDataStore({ - ...reducerSessionData, - ...reducerApiKeyData, - }); - - attachStoreToWindow(store); -}; - -/** - * This test is disabled until we can see the issue below being resolved in - * msw. - * - * https://github.com/mswjs/msw/issues/1785 - */ -// eslint-disable-next-line jest/no-disabled-tests -test.skip('', async () => { - onClientEntry(); - +test('UserContextWrapper publishes session and demo api keys via UserContext', async () => { render( - - - {({ sessionState, apiKeys }) => { - return ( + + + + {({ sessionState, apps }) => ( <>
{JSON.stringify(sessionState)}
-
{JSON.stringify(apiKeys)}
+
{JSON.stringify(apps)}
- ); - }} -
-
, + )} +
+
+ , ); - expect(await screen.getByText('DEMO:API-KEY G')).toBeInTheDocument(); + expect(await screen.findByText(/DEMO:API-KEY/)).toBeInTheDocument(); }); diff --git a/src/contexts/user-context.ts b/src/contexts/user-context.ts index fb0d12fd64..a0040934fc 100644 --- a/src/contexts/user-context.ts +++ b/src/contexts/user-context.ts @@ -50,20 +50,13 @@ export type SessionState = { hubspot?: AblyHubspotData; } & SessionData; -type WildcardCapability = Record; - -export type AppApiKey = { - ui_compatible_capabilities: boolean; - capability: WildcardCapability; - revocableTokens: boolean; - paas_linked: boolean; - is_webhook: boolean; - webhook_url: string; - whole_key: string; - created: string; +// Only the two fields the docs site actually reads. The upstream endpoint +// returns more, but the api-key fetcher narrows down to this pair before +// publishing to UserContext. +export interface AppApiKey { name: string; - id: string; -}; + whole_key: string; +} export type App = { name: string; diff --git a/src/contexts/user-context/api-keys.test.ts b/src/contexts/user-context/api-keys.test.ts new file mode 100644 index 0000000000..5222684447 --- /dev/null +++ b/src/contexts/user-context/api-keys.test.ts @@ -0,0 +1,116 @@ +import { http, HttpResponse } from 'msw'; + +import { server } from '../../../mocks/server'; +import { fetchApps, WEB_API_KEYS_DATA_ENDPOINT, WEB_API_TEMP_KEY_ENDPOINT } from './api-keys'; + +const APP_KEYS_URL = 'https://example.test/apps/a/keys'; + +beforeEach(() => { + // Stub the ad hoc window globals the demo-key flow writes to. They're not + // asserted on directly; the cleanup is so test runs stay isolated. + (window as unknown as { ably?: unknown }).ably = { + docs: { + DOCS_API_KEY: false, + randomChannelName: '', + onApiKeyRetrieved: () => undefined, + }, + }; +}); + +afterEach(() => { + delete (window as unknown as { ably?: unknown }).ably; +}); + +describe('fetchApps', () => { + it('returns the demo app plus any real apps when both endpoints respond', async () => { + server.use( + http.get(WEB_API_TEMP_KEY_ENDPOINT, () => HttpResponse.text('DEMO:KEY')), + http.get(WEB_API_KEYS_DATA_ENDPOINT, () => HttpResponse.json({ data: [{ name: 'My App', url: APP_KEYS_URL }] })), + http.get(APP_KEYS_URL, () => HttpResponse.json([{ name: 'Root', whole_key: 'REAL:KEY' }])), + ); + + const apps = await fetchApps(); + + expect(apps).toHaveLength(2); + expect(apps[0]).toMatchObject({ demo: true, apiKeys: [{ whole_key: 'DEMO:KEY' }] }); + expect(apps[1]).toMatchObject({ + demo: false, + name: 'My App', + url: APP_KEYS_URL, + apiKeys: [{ name: 'Root', whole_key: 'REAL:KEY' }], + }); + }); + + it('still returns real apps when the demo-key endpoint is down', async () => { + server.use( + http.get(WEB_API_TEMP_KEY_ENDPOINT, () => new HttpResponse(null, { status: 503 })), + http.get(WEB_API_KEYS_DATA_ENDPOINT, () => HttpResponse.json({ data: [{ name: 'My App', url: APP_KEYS_URL }] })), + http.get(APP_KEYS_URL, () => HttpResponse.json([{ name: 'Root', whole_key: 'REAL:KEY' }])), + ); + + const apps = await fetchApps(); + + expect(apps).toHaveLength(1); + expect(apps[0]).toMatchObject({ demo: false, apiKeys: [{ whole_key: 'REAL:KEY' }] }); + }); + + it('returns the demo app alone when the api-keys endpoint fails', async () => { + server.use( + http.get(WEB_API_TEMP_KEY_ENDPOINT, () => HttpResponse.text('DEMO:KEY')), + http.get(WEB_API_KEYS_DATA_ENDPOINT, () => new HttpResponse(null, { status: 500 })), + ); + + const apps = await fetchApps(); + + expect(apps).toEqual([ + expect.objectContaining({ demo: true, apiKeys: [expect.objectContaining({ whole_key: 'DEMO:KEY' })] }), + ]); + }); + + it('returns just the demo app when api-keys payload signals "not-found"', async () => { + server.use( + http.get(WEB_API_TEMP_KEY_ENDPOINT, () => HttpResponse.text('DEMO:KEY')), + http.get(WEB_API_KEYS_DATA_ENDPOINT, () => HttpResponse.json({ error: 'not-found' })), + ); + + const apps = await fetchApps(); + + expect(apps).toEqual([ + expect.objectContaining({ demo: true, apiKeys: [expect.objectContaining({ whole_key: 'DEMO:KEY' })] }), + ]); + }); + + it('returns an empty list when both endpoints fail', async () => { + server.use( + http.get(WEB_API_TEMP_KEY_ENDPOINT, () => new HttpResponse(null, { status: 500 })), + http.get(WEB_API_KEYS_DATA_ENDPOINT, () => new HttpResponse(null, { status: 500 })), + ); + + const apps = await fetchApps(); + + expect(apps).toEqual([]); + }); + + it('drops individual real-app entries whose key URL fails', async () => { + const FAILING_URL = 'https://example.test/apps/broken/keys'; + + server.use( + http.get(WEB_API_TEMP_KEY_ENDPOINT, () => HttpResponse.text('DEMO:KEY')), + http.get(WEB_API_KEYS_DATA_ENDPOINT, () => + HttpResponse.json({ + data: [ + { name: 'Working', url: APP_KEYS_URL }, + { name: 'Broken', url: FAILING_URL }, + ], + }), + ), + http.get(APP_KEYS_URL, () => HttpResponse.json([{ name: 'Root', whole_key: 'REAL:KEY' }])), + http.get(FAILING_URL, () => new HttpResponse(null, { status: 404 })), + ); + + const apps = await fetchApps(); + + expect(apps).toHaveLength(2); // demo + working only + expect(apps.map((app) => app.name)).toEqual(['Demo Only', 'Working']); + }); +}); diff --git a/src/contexts/user-context/api-keys.ts b/src/contexts/user-context/api-keys.ts new file mode 100644 index 0000000000..60e5af52e8 --- /dev/null +++ b/src/contexts/user-context/api-keys.ts @@ -0,0 +1,117 @@ +import type { App, AppApiKey } from '../user-context'; + +const WEB_API = process.env.GATSBY_ABLY_MAIN_WEBSITE || 'http://localhost:3000'; + +export const WEB_API_USER_DATA_ENDPOINT = `${WEB_API}/api/me`; +export const WEB_API_KEYS_DATA_ENDPOINT = `${WEB_API}/api/api_keys`; +export const WEB_API_TEMP_KEY_ENDPOINT = `${WEB_API}/ably-auth/api-key/docs`; + +const FETCH_OPTIONS: RequestInit = { cache: 'no-cache' }; + +declare global { + interface Window { + ably: { + docs: { + DOCS_API_KEY: boolean | string; + randomChannelName: string; + onApiKeyRetrieved: () => void; + }; + }; + } +} + +interface ApiKeyValue { + name: string; + url: string; +} + +interface ApiKeysPayload { + data?: ApiKeyValue[]; + error?: unknown; +} + +const safelyInvokeApiKeyRetrievalTrigger = () => { + try { + window.ably.docs.onApiKeyRetrieved(); + } catch (e) { + console.error(e); + } +}; + +const fetchJson = async (url: string, label: string): Promise => { + const res = await fetch(url, FETCH_OPTIONS); + if (!res.ok) { + throw new Error(`${label} endpoint at ${url} returned HTTP ${res.status}`); + } + const contentType = res.headers.get('content-type'); + if (!contentType?.includes('application/json')) { + const text = await res.text(); + throw new Error(`${label} endpoint at ${url} is not serving JSON, received:\n\n${text}`); + } + return res.json() as Promise; +}; + +const fetchDemoApp = async (): Promise => { + const res = await fetch(WEB_API_TEMP_KEY_ENDPOINT, FETCH_OPTIONS); + if (!res.ok) { + throw new Error(`temp-key endpoint at ${WEB_API_TEMP_KEY_ENDPOINT} returned HTTP ${res.status}`); + } + const tempApiKey = await res.text(); + + if (window.ably?.docs && !window.ably.docs.DOCS_API_KEY) { + window.ably.docs.DOCS_API_KEY = tempApiKey; + safelyInvokeApiKeyRetrievalTrigger(); + } + + return { + name: 'Demo Only', + demo: true, + url: WEB_API_TEMP_KEY_ENDPOINT, + apiKeys: [{ name: 'Demo Only', whole_key: tempApiKey }], + }; +}; + +export const fetchApps = async (): Promise => { + // Best-effort: a temp-key outage shouldn't block the signed-in api-key list. + let demoApp: App | null = null; + try { + demoApp = await fetchDemoApp(); + } catch (e) { + console.warn('Could not fetch demo api key:', e); + } + + let payload: ApiKeysPayload; + try { + payload = await fetchJson(WEB_API_KEYS_DATA_ENDPOINT, 'api-keys'); + } catch (e) { + console.warn('Could not fetch api keys due to error:', e); + return demoApp ? [demoApp] : []; + } + + if (payload.error || !Array.isArray(payload.data)) { + return demoApp ? [demoApp] : []; + } + + const realApps = await Promise.all( + payload.data.map(async (entry): Promise => { + try { + const apiKeysRaw = await fetchJson(entry.url, 'api-key-retrieval'); + const apiKeys: AppApiKey[] = apiKeysRaw.map(({ name, whole_key }) => ({ name, whole_key })); + return { ...entry, apiKeys, demo: false }; + } catch (e) { + console.warn(`Could not fetch api keys for ${entry.url}:`, e); + return null; + } + }), + ); + + const apps = realApps.filter((app): app is App => app !== null); + + // Supporting ad hoc scripts; remove when those scripts are. + if (window.ably?.docs && apps[0]?.apiKeys[0]?.whole_key) { + window.ably.docs.DOCS_API_KEY = apps[0].apiKeys[0].whole_key; + safelyInvokeApiKeyRetrievalTrigger(); + } + + return demoApp ? [demoApp, ...apps] : apps; +}; diff --git a/src/contexts/user-context/wrap-with-provider.tsx b/src/contexts/user-context/wrap-with-provider.tsx index 9f1f9dcbeb..e36980aa4e 100644 --- a/src/contexts/user-context/wrap-with-provider.tsx +++ b/src/contexts/user-context/wrap-with-provider.tsx @@ -1,48 +1,52 @@ -import React, { useContext, useEffect, useState } from 'react'; - -import { connectState, selectSessionData, fetchSessionData, getRemoteDataStore } from '@ably/ui/core/scripts'; -import { fetchApiKeyData } from 'src/redux/api-key'; -import { selectData } from 'src/redux/select-data'; -import { - API_KEYS_REDUCER_KEY, - WEB_API_KEYS_DATA_ENDPOINT, - WEB_API_USER_DATA_ENDPOINT, -} from 'src/redux/api-key/constants'; +import React, { useEffect, useMemo, useState } from 'react'; +import { SessionDataProvider, useSessionData } from '@ably/ui/core/scripts'; import UserContext, { UserDetails, type App, SessionState } from '../user-context'; +import { fetchApps, WEB_API_USER_DATA_ENDPOINT } from './api-keys'; // // This wrapper component is responsible for loading up our user session and -// user/demo API keys and make it available to all our pages via the -// UserContext. +// user/demo API keys and making both available via UserContext. Session data +// comes from SessionDataProvider (SWR-backed) in @ably/ui; API keys are +// fetched directly because the shape is docs-specific. // export const UserContextWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const userContext = useContext(UserContext); - const [userState, setUserState] = useState(userContext); + const { sessionData } = useSessionData(); + const [apps, setApps] = useState([]); useEffect(() => { - const store = getRemoteDataStore(); - - connectState(selectSessionData, (session: SessionState) => { - setUserState((existing) => ({ ...existing, sessionState: session })); - }); - - fetchSessionData(store, WEB_API_USER_DATA_ENDPOINT); - - connectState(selectData(API_KEYS_REDUCER_KEY), (state: { data?: App[] }) => { - const data = Array.isArray(state?.data) ? state.data : []; - setUserState((existing) => ({ ...existing, apps: data })); - }); - - fetchApiKeyData(store, WEB_API_KEYS_DATA_ENDPOINT); + let cancelled = false; + const loadApps = async () => { + try { + const next = await fetchApps(); + if (!cancelled) { + setApps(next); + } + } catch (e) { + console.warn('Could not load api keys:', e); + } + }; + void loadApps(); + return () => { + cancelled = true; + }; }, []); - return {children}; + const value = useMemo( + () => ({ + sessionState: (sessionData ?? {}) as unknown as SessionState, + apps, + }), + [sessionData, apps], + ); + + return {children}; }; -// This little indirection is needed so we can make use of hooks in our wrapper component const wrapRootElement = ({ element }: { element: React.ReactNode }) => ( - {element} + + {element} + ); export default wrapRootElement; diff --git a/src/redux/api-key/api-key-reducer.ts b/src/redux/api-key/api-key-reducer.ts deleted file mode 100644 index 80b346877f..0000000000 --- a/src/redux/api-key/api-key-reducer.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { API_KEY_LOADED_EVENT, API_KEYS_REDUCER_KEY } from './constants'; - -export type ApiKeyValue = { - name: string; - url: string; -}; - -type ApiKeyState = { - data: Array>; -}; - -type Action = { - payload: Record; - type: typeof API_KEY_LOADED_EVENT; -}; - -const initialState: ApiKeyState = { data: [] }; - -export const reducerApiKeyData = { - [API_KEYS_REDUCER_KEY]: (state: ApiKeyState = initialState, action: Action) => { - switch (action.type) { - case API_KEY_LOADED_EVENT: - return { ...state, data: action.payload }; - default: - return state; - } - }, -}; diff --git a/src/redux/api-key/constants.ts b/src/redux/api-key/constants.ts deleted file mode 100644 index c15a9abb5a..0000000000 --- a/src/redux/api-key/constants.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const API_KEY_LOADED_EVENT = 'apikeys/loaded'; -export const API_KEYS_REDUCER_KEY = 'api-key'; -const WEB_API = process.env.GATSBY_ABLY_MAIN_WEBSITE || 'http://localhost:3000'; -export const WEB_API_USER_DATA_ENDPOINT = `${WEB_API}/api/me`; -export const WEB_API_KEYS_DATA_ENDPOINT = `${WEB_API}/api/api_keys`; -export const WEB_API_TEMP_KEY_ENDPOINT = `${WEB_API}/ably-auth/api-key/docs`; diff --git a/src/redux/api-key/index.ts b/src/redux/api-key/index.ts deleted file mode 100644 index 8c0f734ae7..0000000000 --- a/src/redux/api-key/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './api-key-reducer'; -export * from './remote-api-key-data'; diff --git a/src/redux/api-key/remote-api-key-data.ts b/src/redux/api-key/remote-api-key-data.ts deleted file mode 100644 index ec8d2fe0e7..0000000000 --- a/src/redux/api-key/remote-api-key-data.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { isArray } from 'lodash'; -import { pick } from 'lodash/fp'; -import { addDataToStore, DEFAULT_CACHE_STRATEGY, getJsonResponse } from '../fetch-and-add-to-store'; -import { ApiKeyValue } from './api-key-reducer'; -import { API_KEY_LOADED_EVENT, WEB_API_TEMP_KEY_ENDPOINT } from './constants'; - -declare global { - interface Window { - ably: { - docs: { - DOCS_API_KEY: boolean | string; - randomChannelName: string; - onApiKeyRetrieved: () => void; - }; - }; - } -} - -const safelyInvokeApiKeyRetrievalTrigger = () => { - try { - window.ably.docs.onApiKeyRetrieved(); - } catch (e) { - console.error(e); - } -}; - -const fetchDemoApiKey = async () => { - const tempApiKeyResponse = await fetch(WEB_API_TEMP_KEY_ENDPOINT, { cache: DEFAULT_CACHE_STRATEGY }); - const tempApiKey = await tempApiKeyResponse.text(); - - if (window.ably?.docs && !window.ably.docs.DOCS_API_KEY) { - window.ably.docs.DOCS_API_KEY = tempApiKey; - safelyInvokeApiKeyRetrievalTrigger(); - } - - return { - name: 'Demo Only', - demo: true, - url: WEB_API_TEMP_KEY_ENDPOINT, - apiKeys: [ - { - name: 'Demo Only', - whole_key: tempApiKey, - }, - ], - }; -}; - -const retrieveApiKeyDataFromApiKeyUrl = async (payload: Record) => { - // Always fetch the demo key - const demoApiKey = await fetchDemoApiKey(); - - // If there's no valid payload, return only the demo key - if (payload.error || !payload.data || !isArray(payload.data)) { - return { - data: [demoApiKey], - }; - } - - // Fetch actual API keys from the payload - const apiKeyData = await Promise.all( - payload.data.map(async (value: ApiKeyValue) => { - try { - const apiKeysRaw = await getJsonResponse(value.url, 'api-key-retrieval'); - const apiKeys = apiKeysRaw.map(pick(['name', 'whole_key'])); - return { ...value, apiKeys, demo: false }; - } catch (error) { - return null; - } - }), - ); - - /** - * Supporting ad hoc scripts; the following lines can be removed when ad hoc scripts are. - */ - if (window.ably?.docs && apiKeyData[0]) { - window.ably.docs.DOCS_API_KEY = apiKeyData[0].apiKeys[0].whole_key; - safelyInvokeApiKeyRetrievalTrigger(); - } - /** - * Supporting ad hoc scripts; the preceding lines can be removed when ad hoc scripts are. - */ - - // Return both actual keys and demo key - return { - ...payload, - data: [demoApiKey, ...apiKeyData.filter(Boolean)], - }; -}; - -export const fetchApiKeyData = async (store: Store, apiKeyUrl: string): Promise => - addDataToStore(store, apiKeyUrl, API_KEY_LOADED_EVENT, retrieveApiKeyDataFromApiKeyUrl); diff --git a/src/redux/fetch-and-add-to-store.ts b/src/redux/fetch-and-add-to-store.ts deleted file mode 100644 index 1af393ff11..0000000000 --- a/src/redux/fetch-and-add-to-store.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * cf. src/core/remote-session-data.js Ably UI repository - */ -import { identity } from 'lodash'; - -const NOT_FOUND_ERROR_CODE = 'not-found'; -export const DEFAULT_CACHE_STRATEGY = 'no-cache'; - -const isJsonResponse = (res: Response): boolean => { - const contentType = res.headers.get('content-type'); - return !!contentType && contentType.includes('application/json'); -}; - -export const getJsonResponse = async (url: string, type: string) => { - const res = await fetch(url, { cache: DEFAULT_CACHE_STRATEGY }); - const jsonResponse = isJsonResponse(res); - if (!jsonResponse) { - const text = await res.text(); - throw new Error(`${type} endpoint at ${url} is not serving JSON, received:\n\n${text}`); - } - - const payload = await res.json(); - return payload; -}; - -export type DataProcessor = (payload: Record) => Promise>; - -export const addDataToStore = async ( - store: Store, - url: string, - type: string, - dataProcessor: DataProcessor = identity, -): Promise => { - try { - if (!url) { - console.warn(`Skipping fetching data of type ${type}, invalid URL: ${url}`); - dataLoaded({}, store, type); - return; - } - - const payload = await getJsonResponse(url, type); - - const processedPayload = await dataProcessor(payload); - - switch (processedPayload.error) { - case NOT_FOUND_ERROR_CODE: - dataLoaded({}, store, type); - return; - default: - dataLoaded(processedPayload, store, type); - return; - } - } catch (e) { - dataLoaded({}, store, type); - console.warn(`Could not fetch ${type} data due to error:`, e); - } -}; - -export const dataLoaded = (payload: Record, store: Store, type: string): void => - store.dispatch({ type, payload }); diff --git a/src/redux/select-data.ts b/src/redux/select-data.ts deleted file mode 100644 index 47b3034e46..0000000000 --- a/src/redux/select-data.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const selectData = - (key: string) => - (store: Store): Record => { - const result = store.getState()[key]; - return result && result.data ? result.data : {}; - }; diff --git a/src/types/ably-ui-core-scripts/index.d.ts b/src/types/ably-ui-core-scripts/index.d.ts deleted file mode 100644 index ce9a16bd98..0000000000 --- a/src/types/ably-ui-core-scripts/index.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -type Store = { - getState: () => Record< - string, - { - data: Record; - } - >; - dispatch: (options: { type: string; payload: Record }) => void; -}; - -type selector = (store: Store) => Record; - -type connectState = (selector: selector, setState: Dispatch>) => void; diff --git a/src/utilities/update-ably-connection-keys.test.ts b/src/utilities/update-ably-connection-keys.test.ts index 008259d981..64974856e4 100644 --- a/src/utilities/update-ably-connection-keys.test.ts +++ b/src/utilities/update-ably-connection-keys.test.ts @@ -7,15 +7,7 @@ const originalEnv = process.env; // Test fixtures const createMockAppApiKey = (whole_key: string): AppApiKey => ({ whole_key, - ui_compatible_capabilities: true, - capability: {}, - revocableTokens: true, - paas_linked: false, - is_webhook: false, - webhook_url: '', - created: '2023-01-01', name: 'Test Key', - id: 'test-id', }); const createUserDataWithBothApps = (): UserDetails => ({