feat: Workspace Proxy picker show latency to each proxy (#7486)

* chore: Add cors to workspace proxies to allow for latency checks
* Add latency check to wsproxy

Use performance API timings.
- Fix cors and timing headers
- Accept custom headers
This commit is contained in:
Steven Masley
2023-05-11 15:42:30 -05:00
committed by GitHub
parent 640fcf450c
commit 8f768f8276
22 changed files with 347 additions and 28 deletions
+11
View File
@@ -806,6 +806,17 @@ func New(options *Options) *API {
return []string{}
})
r.NotFound(cspMW(compressHandler(http.HandlerFunc(api.siteHandler.ServeHTTP))).ServeHTTP)
// This must be before all middleware to improve the response time.
// So make a new router, and mount the old one as the root.
rootRouter := chi.NewRouter()
// This is the only route we add before all the middleware.
// We want to time the latency of the request, so any middleware will
// interfere with that timing.
rootRouter.Get("/latency-check", LatencyCheck(api.AccessURL))
rootRouter.Mount("/", r)
api.RootHandler = rootRouter
return api
}
+9
View File
@@ -124,6 +124,15 @@ func TestDERPLatencyCheck(t *testing.T) {
require.Equal(t, http.StatusOK, res.StatusCode)
}
func TestFastLatencyCheck(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
res, err := client.Request(context.Background(), http.MethodGet, "/latency-check", nil)
require.NoError(t, err)
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode)
}
func TestHealthz(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
+24
View File
@@ -0,0 +1,24 @@
package coderd
import (
"net/http"
"net/url"
"strings"
)
func LatencyCheck(allowedOrigins ...*url.URL) http.HandlerFunc {
allowed := make([]string, 0, len(allowedOrigins))
for _, origin := range allowedOrigins {
// Allow the origin without a path
tmp := *origin
tmp.Path = ""
allowed = append(allowed, strings.TrimSuffix(origin.String(), "/"))
}
origins := strings.Join(allowed, ",")
return func(rw http.ResponseWriter, r *http.Request) {
// Allowing timing information to be shared. This allows the browser
// to exclude TLS handshake timing.
rw.Header().Set("Timing-Allow-Origin", origins)
rw.WriteHeader(http.StatusOK)
}
}
+1
View File
@@ -175,6 +175,7 @@ func TestRegions(t *testing.T) {
})
t.Run("GoingAway", func(t *testing.T) {
t.Skip("This is flakey in CI because it relies on internal go routine timing. Should refactor.")
t.Parallel()
dv := coderdtest.DeploymentValues(t)
+24 -14
View File
@@ -19,6 +19,7 @@ import (
"cdr.dev/slog"
"github.com/coder/coder/buildinfo"
"github.com/coder/coder/coderd"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/coderd/tracing"
@@ -186,6 +187,21 @@ func New(ctx context.Context, opts *Options) (*Server, error) {
SecureAuthCookie: opts.SecureAuthCookie,
}
// The primary coderd dashboard needs to make some GET requests to
// the workspace proxies to check latency.
corsMW := cors.Handler(cors.Options{
AllowedOrigins: []string{
// Allow the dashboard to make requests to the proxy for latency
// checks.
opts.DashboardURL.String(),
},
// Only allow GET requests for latency checks.
AllowedMethods: []string{http.MethodOptions, http.MethodGet},
AllowedHeaders: []string{"Accept", "Content-Type", "X-LATENCY-CHECK", "X-CSRF-TOKEN"},
// Do not send any cookies
AllowCredentials: false,
})
// Routes
apiRateLimiter := httpmw.RateLimit(opts.APIRateLimit, time.Minute)
// Persistent middlewares to all routes
@@ -198,20 +214,7 @@ func New(ctx context.Context, opts *Options) (*Server, error) {
httpmw.ExtractRealIP(s.Options.RealIPConfig),
httpmw.Logger(s.Logger),
httpmw.Prometheus(s.PrometheusRegistry),
// The primary coderd dashboard needs to make some GET requests to
// the workspace proxies to check latency.
cors.Handler(cors.Options{
AllowedOrigins: []string{
// Allow the dashboard to make requests to the proxy for latency
// checks.
opts.DashboardURL.String(),
},
// Only allow GET requests for latency checks.
AllowedMethods: []string{http.MethodGet},
AllowedHeaders: []string{"Accept", "Content-Type"},
// Do not send any cookies
AllowCredentials: false,
}),
corsMW,
// HandleSubdomain is a middleware that handles all requests to the
// subdomain-based workspace apps.
@@ -260,6 +263,13 @@ func New(ctx context.Context, opts *Options) (*Server, error) {
})
})
// See coderd/coderd.go for why we need this.
rootRouter := chi.NewRouter()
// Make sure to add the cors middleware to the latency check route.
rootRouter.Get("/latency-check", corsMW(coderd.LatencyCheck(s.DashboardURL, s.AppServer.AccessURL)).ServeHTTP)
rootRouter.Mount("/", r)
s.Handler = rootRouter
return s, nil
}
+24
View File
@@ -6,9 +6,33 @@ import "jest-location-mock"
import { TextEncoder, TextDecoder } from "util"
import { Blob } from "buffer"
import jestFetchMock from "jest-fetch-mock"
import { ProxyLatencyReport } from "contexts/useProxyLatency"
import { RegionsResponse } from "api/typesGenerated"
jestFetchMock.enableMocks()
// useProxyLatency does some http requests to determine latency.
// This would fail unit testing, or at least make it very slow with
// actual network requests. So just globally mock this hook.
jest.mock("contexts/useProxyLatency", () => ({
useProxyLatency: (proxies?: RegionsResponse) => {
if (!proxies) {
return {} as Record<string, ProxyLatencyReport>
}
return proxies.regions.reduce((acc, proxy) => {
acc[proxy.id] = {
accurate: true,
// Return a constant latency of 8ms.
// If you make this random it could break stories.
latencyMS: 8,
at: new Date(),
}
return acc
}, {} as Record<string, ProxyLatencyReport>)
},
}))
global.TextEncoder = TextEncoder
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Polyfill for jsdom
global.TextDecoder = TextDecoder as any
+1
View File
@@ -30,6 +30,7 @@
"dependencies": {
"@emoji-mart/data": "1.0.5",
"@emoji-mart/react": "1.0.1",
"@fastly/performance-observer-polyfill": "^2.0.0",
"@emotion/react": "^11.10.8",
"@emotion/styled": "^11.10.8",
"@fontsource/ibm-plex-mono": "4.5.10",
@@ -5,6 +5,7 @@ import {
MockWorkspace,
MockWorkspaceAgent,
MockWorkspaceApp,
MockProxyLatencies,
} from "testHelpers/entities"
import { AppLink, AppLinkProps } from "./AppLink"
import { ProxyContext, getPreferredProxy } from "contexts/ProxyContext"
@@ -17,6 +18,7 @@ export default {
const Template: Story<AppLinkProps> = (args) => (
<ProxyContext.Provider
value={{
proxyLatencies: MockProxyLatencies,
proxy: getPreferredProxy(MockWorkspaceProxies, MockPrimaryWorkspaceProxy),
proxies: MockWorkspaceProxies,
isLoading: false,
+3 -10
View File
@@ -1,5 +1,6 @@
import { useRef, useState, FC } from "react"
import { makeStyles, useTheme } from "@mui/styles"
import { Theme } from "@mui/material/styles"
import {
HelpTooltipText,
HelpPopover,
@@ -7,7 +8,7 @@ import {
} from "components/Tooltips/HelpTooltip"
import { Stack } from "components/Stack/Stack"
import { WorkspaceAgent, DERPRegion } from "api/typesGenerated"
import { Theme } from "@mui/material/styles"
import { getLatencyColor } from "utils/colors"
const getDisplayLatency = (theme: Theme, agent: WorkspaceAgent) => {
// Find the right latency to display
@@ -22,17 +23,9 @@ const getDisplayLatency = (theme: Theme, agent: WorkspaceAgent) => {
return undefined
}
// Get the color
let color = theme.palette.success.light
if (latency.latency_ms >= 150 && latency.latency_ms < 300) {
color = theme.palette.warning.light
} else if (latency.latency_ms >= 300) {
color = theme.palette.error.light
}
return {
...latency,
color,
color: getLatencyColor(theme, latency.latency_ms),
}
}
@@ -16,6 +16,7 @@ import {
MockWorkspaceAgentStartTimeout,
MockWorkspaceAgentTimeout,
MockWorkspaceApp,
MockProxyLatencies,
} from "testHelpers/entities"
import { AgentRow, AgentRowProps } from "./AgentRow"
import { ProxyContext, getPreferredProxy } from "contexts/ProxyContext"
@@ -56,6 +57,7 @@ const TemplateFC = (
return (
<ProxyContext.Provider
value={{
proxyLatencies: MockProxyLatencies,
proxy: getPreferredProxy(proxies, selectedProxy),
proxies: proxies,
isLoading: false,
@@ -1,6 +1,10 @@
import { action } from "@storybook/addon-actions"
import { Story } from "@storybook/react"
import { MockWorkspace, MockWorkspaceResource } from "testHelpers/entities"
import {
MockProxyLatencies,
MockWorkspace,
MockWorkspaceResource,
} from "testHelpers/entities"
import { AgentRow } from "./AgentRow"
import { ResourceCard, ResourceCardProps } from "./ResourceCard"
import { ProxyContext, getPreferredProxy } from "contexts/ProxyContext"
@@ -18,6 +22,7 @@ Example.args = {
agentRow: (agent) => (
<ProxyContext.Provider
value={{
proxyLatencies: MockProxyLatencies,
proxy: getPreferredProxy([], undefined),
proxies: [],
isLoading: false,
@@ -84,6 +89,7 @@ BunchOfMetadata.args = {
agentRow: (agent) => (
<ProxyContext.Provider
value={{
proxyLatencies: MockProxyLatencies,
proxy: getPreferredProxy([], undefined),
proxies: [],
isLoading: false,
@@ -7,6 +7,7 @@ import { Workspace, WorkspaceErrors, WorkspaceProps } from "./Workspace"
import { withReactContext } from "storybook-react-context"
import EventSource from "eventsourcemock"
import { ProxyContext, getPreferredProxy } from "contexts/ProxyContext"
import { MockProxyLatencies } from "../../testHelpers/entities"
export default {
title: "components/Workspace",
@@ -26,6 +27,7 @@ export default {
const Template: Story<WorkspaceProps> = (args) => (
<ProxyContext.Provider
value={{
proxyLatencies: MockProxyLatencies,
proxy: getPreferredProxy([], undefined),
proxies: [],
isLoading: false,
+7
View File
@@ -9,10 +9,12 @@ import {
useContext,
useState,
} from "react"
import { ProxyLatencyReport, useProxyLatency } from "./useProxyLatency"
interface ProxyContextValue {
proxy: PreferredProxy
proxies?: Region[]
proxyLatencies?: Record<string, ProxyLatencyReport>
// isfetched is true when the proxy api call is complete.
isFetched: boolean
// isLoading is true if the proxy is in the process of being fetched.
@@ -72,6 +74,10 @@ export const ProxyProvider: FC<PropsWithChildren> = ({ children }) => {
},
})
// Everytime we get a new proxiesResponse, update the latency check
// to each workspace proxy.
const proxyLatencies = useProxyLatency(proxiesResp)
const setAndSaveProxy = (
selectedProxy?: Region,
// By default the proxies come from the api call above.
@@ -95,6 +101,7 @@ export const ProxyProvider: FC<PropsWithChildren> = ({ children }) => {
return (
<ProxyContext.Provider
value={{
proxyLatencies: proxyLatencies,
proxy: experimentEnabled
? proxy
: {
+152
View File
@@ -0,0 +1,152 @@
import { Region, RegionsResponse } from "api/typesGenerated"
import { useEffect, useReducer } from "react"
import PerformanceObserver from "@fastly/performance-observer-polyfill"
import axios from "axios"
import { generateRandomString } from "utils/random"
export interface ProxyLatencyReport {
// accurate identifies if the latency was calculated using the
// PerformanceResourceTiming API. If this is false, then the
// latency is calculated using the total duration of the request
// and will be off by a good margin.
accurate: boolean
latencyMS: number
// at is when the latency was recorded.
at: Date
}
interface ProxyLatencyAction {
proxyID: string
report: ProxyLatencyReport
}
const proxyLatenciesReducer = (
state: Record<string, ProxyLatencyReport>,
action: ProxyLatencyAction,
): Record<string, ProxyLatencyReport> => {
// Just overwrite any existing latency.
state[action.proxyID] = action.report
return state
}
export const useProxyLatency = (
proxies?: RegionsResponse,
): Record<string, ProxyLatencyReport> => {
const [proxyLatencies, dispatchProxyLatencies] = useReducer(
proxyLatenciesReducer,
{},
)
// Only run latency updates when the proxies change.
useEffect(() => {
if (!proxies) {
return
}
// proxyMap is a map of the proxy path_app_url to the proxy object.
// This is for the observer to know which requests are important to
// record.
const proxyChecks = proxies.regions.reduce((acc, proxy) => {
// Only run the latency check on healthy proxies.
if (!proxy.healthy) {
return acc
}
// Add a random query param to the url to make sure we don't get a cached response.
// This is important in case there is some caching layer between us and the proxy.
const url = new URL(
`/latency-check?cache_bust=${generateRandomString(6)}`,
proxy.path_app_url,
)
acc[url.toString()] = proxy
return acc
}, {} as Record<string, Region>)
// dispatchProxyLatenciesGuarded will assign the latency to the proxy
// via the reducer. But it will only do so if the performance entry is
// a resource entry that we care about.
const dispatchProxyLatenciesGuarded = (entry: PerformanceEntry): void => {
if (entry.entryType !== "resource") {
// We should never get these, but just in case.
return
}
// The entry.name is the url of the request.
const check = proxyChecks[entry.name]
if (!check) {
// This is not a proxy request, so ignore it.
return
}
// These docs are super useful.
// https://developer.mozilla.org/en-US/docs/Web/API/Performance_API/Resource_timing
let latencyMS = 0
let accurate = false
if (
"requestStart" in entry &&
(entry as PerformanceResourceTiming).requestStart !== 0
) {
// This is the preferred logic to get the latency.
const timingEntry = entry as PerformanceResourceTiming
latencyMS = timingEntry.responseStart - timingEntry.requestStart
accurate = true
} else {
// This is the total duration of the request and will be off by a good margin.
// This is a fallback if the better timing is not available.
// eslint-disable-next-line no-console -- We can remove this when we display the "accurate" bool on the UI
console.log(
`Using fallback latency calculation for "${entry.name}". Latency will be incorrect and larger then actual.`,
)
latencyMS = entry.duration
}
dispatchProxyLatencies({
proxyID: check.id,
report: {
latencyMS,
accurate,
at: new Date(),
},
})
return
}
// Start a new performance observer to record of all the requests
// to the proxies.
const observer = new PerformanceObserver((list) => {
// If we get entries via this callback, then dispatch the events to the latency reducer.
list.getEntries().forEach((entry) => {
dispatchProxyLatenciesGuarded(entry)
})
})
// The resource requests include xmlhttp requests.
observer.observe({ entryTypes: ["resource"] })
const proxyRequests = Object.keys(proxyChecks).map((latencyURL) => {
return axios.get(latencyURL, {
withCredentials: false,
// Must add a custom header to make the request not a "simple request"
// https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#simple_requests
headers: { "X-LATENCY-CHECK": "true" },
})
})
// When all the proxy requests finish
Promise.all(proxyRequests)
// TODO: If there is an error on any request, we might want to store some indicator of that?
.finally(() => {
// takeRecords will return any entries that were not called via the callback yet.
// We want to call this before we disconnect the observer to make sure we get all the
// proxy requests recorded.
observer.takeRecords().forEach((entry) => {
dispatchProxyLatenciesGuarded(entry)
})
// At this point, we can be confident that all the proxy requests have been recorded
// via the performance observer. So we can disconnect the observer.
observer.disconnect()
})
}, [proxies])
return proxyLatencies
}
@@ -4,6 +4,7 @@ import WS from "jest-websocket-mock"
import { rest } from "msw"
import {
MockPrimaryWorkspaceProxy,
MockProxyLatencies,
MockWorkspace,
MockWorkspaceAgent,
MockWorkspaceProxies,
@@ -43,6 +44,7 @@ const renderTerminal = () => {
element={
<ProxyContext.Provider
value={{
proxyLatencies: MockProxyLatencies,
proxy: {
selectedProxy: MockPrimaryWorkspaceProxy,
preferredPathAppURL: "",
@@ -14,6 +14,7 @@ export const WorkspaceProxyPage: FC<PropsWithChildren<unknown>> = () => {
"This selection only affects browser connections to your workspace."
const {
proxyLatencies,
proxies,
error: proxiesError,
isFetched: proxiesFetched,
@@ -30,6 +31,7 @@ export const WorkspaceProxyPage: FC<PropsWithChildren<unknown>> = () => {
layout="fluid"
>
<WorkspaceProxyView
proxyLatencies={proxyLatencies}
proxies={proxies}
isLoading={proxiesLoading}
hasLoaded={proxiesFetched}
@@ -9,15 +9,19 @@ import {
HealthyBadge,
NotHealthyBadge,
} from "components/DeploySettingsLayout/Badges"
import { makeStyles } from "@mui/styles"
import { makeStyles, useTheme } from "@mui/styles"
import { combineClasses } from "utils/combineClasses"
import { ProxyLatencyReport } from "contexts/useProxyLatency"
import { getLatencyColor } from "utils/colors"
export const ProxyRow: FC<{
latency?: ProxyLatencyReport
proxy: Region
onSelectRegion: (proxy: Region) => void
preferred: boolean
}> = ({ proxy, onSelectRegion, preferred }) => {
}> = ({ proxy, onSelectRegion, preferred, latency }) => {
const styles = useStyles()
const theme = useTheme()
const clickable = useClickableTableRow(() => {
onSelectRegion(proxy)
@@ -53,6 +57,15 @@ export const ProxyRow: FC<{
<TableCell>
<ProxyStatus proxy={proxy} />
</TableCell>
<TableCell>
<span
style={{
color: latency ? getLatencyColor(theme, latency.latencyMS) : "",
}}
>
{latency ? `${latency.latencyMS.toFixed(1)} ms` : "?"}
</span>
</TableCell>
</TableRow>
)
}
@@ -12,9 +12,11 @@ import { FC } from "react"
import { AlertBanner } from "components/AlertBanner/AlertBanner"
import { Region } from "api/typesGenerated"
import { ProxyRow } from "./WorkspaceProxyRow"
import { ProxyLatencyReport } from "contexts/useProxyLatency"
export interface WorkspaceProxyViewProps {
proxies?: Region[]
proxyLatencies?: Record<string, ProxyLatencyReport>
getWorkspaceProxiesError?: Error | unknown
isLoading: boolean
hasLoaded: boolean
@@ -27,6 +29,7 @@ export const WorkspaceProxyView: FC<
React.PropsWithChildren<WorkspaceProxyViewProps>
> = ({
proxies,
proxyLatencies,
getWorkspaceProxiesError,
isLoading,
hasLoaded,
@@ -49,6 +52,7 @@ export const WorkspaceProxyView: FC<
<TableCell width="40%">Proxy</TableCell>
<TableCell width="30%">URL</TableCell>
<TableCell width="10%">Status</TableCell>
<TableCell width="20%">Latency</TableCell>
</TableRow>
</TableHead>
<TableBody>
@@ -62,6 +66,7 @@ export const WorkspaceProxyView: FC<
<Cond>
{proxies?.map((proxy) => (
<ProxyRow
latency={proxyLatencies?.[proxy.id]}
key={proxy.id}
proxy={proxy}
onSelectRegion={onSelect}
@@ -4,6 +4,7 @@ import {
MockWorkspaceProxies,
MockPrimaryWorkspaceProxy,
MockHealthyWildWorkspaceProxy,
MockProxyLatencies,
} from "testHelpers/entities"
import {
WorkspaceProxyView,
@@ -27,6 +28,7 @@ PrimarySelected.args = {
isLoading: false,
hasLoaded: true,
proxies: MockWorkspaceProxies,
proxyLatencies: MockProxyLatencies,
preferredProxy: MockPrimaryWorkspaceProxy,
onSelect: () => {
return Promise.resolve()
@@ -38,6 +40,7 @@ Example.args = {
isLoading: false,
hasLoaded: true,
proxies: MockWorkspaceProxies,
proxyLatencies: MockProxyLatencies,
preferredProxy: MockHealthyWildWorkspaceProxy,
onSelect: () => {
return Promise.resolve()
+29
View File
@@ -7,6 +7,7 @@ import range from "lodash/range"
import { Permissions } from "xServices/auth/authXService"
import { TemplateVersionFiles } from "utils/templateVersion"
import { FileTree } from "utils/filetree"
import { ProxyLatencyReport } from "contexts/useProxyLatency"
export const MockOrganization: TypesGen.Organization = {
id: "fc0774ce-cc9e-48d4-80ae-88f7a4d4a8b0",
@@ -113,6 +114,34 @@ export const MockWorkspaceProxies: TypesGen.Region[] = [
},
]
export const MockProxyLatencies: Record<string, ProxyLatencyReport> = {
...MockWorkspaceProxies.reduce((acc, proxy) => {
if (!proxy.healthy) {
return acc
}
acc[proxy.id] = {
// Make one of them inaccurate.
accurate: proxy.id !== "26e84c16-db24-4636-a62d-aa1a4232b858",
// This is a deterministic way to generate a latency to for each proxy.
// It will be the same for each run as long as the IDs don't change.
latencyMS:
(Number(
Array.from(proxy.id).reduce(
// Multiply each char code by some large prime number to increase the
// size of the number and allow use to get some decimal points.
(acc, char) => acc + char.charCodeAt(0) * 37,
0,
),
) /
// Cap at 250ms
100) %
250,
at: new Date(),
}
return acc
}, {} as Record<string, ProxyLatencyReport>),
}
export const MockBuildInfo: TypesGen.BuildInfoResponse = {
external_url: "file:///mock-url",
version: "v99.999.9999+c9cdf14",
+14
View File
@@ -1,3 +1,5 @@
import { Theme } from "@mui/material/styles"
// Used to convert our theme colors to Hex since monaco theme only support hex colors
// From https://www.jameslmilner.com/posts/converting-rgb-hex-hsl-colors/
export function hslToHex(hsl: string): string {
@@ -21,3 +23,15 @@ export function hslToHex(hsl: string): string {
}
return `#${f(0)}${f(8)}${f(4)}`
}
// getLatencyColor is the text color to use for a given latency
// in milliseconds.
export const getLatencyColor = (theme: Theme, latency: number) => {
let color = theme.palette.success.light
if (latency >= 150 && latency < 300) {
color = theme.palette.warning.light
} else if (latency >= 300) {
color = theme.palette.error.light
}
return color
}
+8 -1
View File
@@ -1458,6 +1458,13 @@
resolved "https://registry.yarnpkg.com/@fal-works/esbuild-plugin-global-externals/-/esbuild-plugin-global-externals-2.1.2.tgz#c05ed35ad82df8e6ac616c68b92c2282bd083ba4"
integrity sha512-cEee/Z+I12mZcFJshKcCqC8tuX5hG3s+d+9nZ3LabqKF1vKdF41B92pJVCBggjAGORAeOzyyDDKrZwIkLffeOQ==
"@fastly/performance-observer-polyfill@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@fastly/performance-observer-polyfill/-/performance-observer-polyfill-2.0.0.tgz#fb697180f92019119d8c55d20216adce6436f941"
integrity sha512-cQC4E6ReYY4Vud+eCJSCr1N0dSz+fk7xJlLiSgPFDHbnFLZo5DenazoersMt9D8JkEhl9Z5ZwJ/8apcjSrdb8Q==
dependencies:
tslib "^2.0.3"
"@fontsource/ibm-plex-mono@4.5.10":
version "4.5.10"
resolved "https://registry.yarnpkg.com/@fontsource/ibm-plex-mono/-/ibm-plex-mono-4.5.10.tgz#25d004646853bf46b3787341300662fe61f8ad78"
@@ -11715,7 +11722,7 @@ tslib@^1.10.0, tslib@^1.13.0, tslib@^1.8.1, tslib@^1.9.3:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
tslib@^2, tslib@^2.0.1, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.5.0:
tslib@^2, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.5.0:
version "2.5.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.0.tgz#42bfed86f5787aeb41d031866c8f402429e0fddf"
integrity sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==