chore: fixing dependencies and code improvements

Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
benjdlambert
2025-09-09 14:21:54 +02:00
parent 4fcd282522
commit 81dd70aebd
5 changed files with 296 additions and 323 deletions
+1 -4
View File
@@ -47,9 +47,7 @@
"test": "backstage-cli package test"
},
"dependencies": {
"@backstage/core-compat-api": "workspace:^",
"@backstage/core-components": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/theme": "workspace:^",
@@ -60,12 +58,11 @@
},
"devDependencies": {
"@backstage/cli": "workspace:^",
"@backstage/core-app-api": "workspace:^",
"@backstage/dev-utils": "workspace:^",
"@backstage/frontend-defaults": "workspace:^",
"@backstage/test-utils": "workspace:^",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^14.0.0",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.0.0",
"@types/react": "^18.0.0",
"msw": "^1.0.0",
@@ -13,8 +13,8 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useCallback, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import {
Box,
Button,
@@ -38,13 +38,7 @@ import {
EmptyState,
ResponseErrorPanel,
} from '@backstage/core-components';
import {
alertApiRef,
useApi,
fetchApiRef,
discoveryApiRef,
} from '@backstage/core-plugin-api';
import { isError } from '@backstage/errors';
import { useConsentSession } from './useConsentSession';
const useStyles = makeStyles(theme => ({
authCard: {
@@ -89,260 +83,164 @@ const useStyles = makeStyles(theme => ({
},
}));
interface Session {
id: string;
clientName?: string;
clientId: string;
redirectUri: string;
scopes?: string[];
responseType?: string;
state?: string;
nonce?: string;
codeChallenge?: string;
codeChallengeMethod?: string;
expiresAt?: string;
}
const ConsentPageLayout = ({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) => (
<Page themeId="tool">
<Header title={title} />
<Content>{children}</Content>
</Page>
);
export const ConsentPage = () => {
const classes = useStyles();
const { sessionId } = useParams<{ sessionId: string }>();
const alertApi = useApi(alertApiRef);
const fetchApi = useApi(fetchApiRef);
const discoveryApi = useApi(discoveryApiRef);
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [completed, setCompleted] = useState<
| {
action: 'approve' | 'reject';
}
| undefined
>(undefined);
useEffect(() => {
const fetchSession = async () => {
if (!sessionId) return;
try {
const baseUrl = await discoveryApi.getBaseUrl('auth');
const response = await fetchApi.fetch(
`${baseUrl}/v1/sessions/${sessionId}`,
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
setSession(data);
} catch (err) {
setError(isError(err) ? err.message : 'Failed to load consent request');
} finally {
setLoading(false);
}
};
fetchSession();
}, [sessionId, discoveryApi, fetchApi]);
const handleAction = useCallback(
async (action: 'approve' | 'reject') => {
if (!session) return;
setSubmitting(true);
try {
const baseUrl = await discoveryApi.getBaseUrl('auth');
const response = await fetchApi.fetch(
`${baseUrl}/v1/sessions/${session.id}/${action}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
setCompleted({
action,
});
if (result.redirectUrl) {
window.location.href = result.redirectUrl;
}
} catch (err) {
alertApi.post({
message: isError(err) ? err.message : `Failed to ${action} consent`,
severity: 'error',
});
} finally {
setSubmitting(false);
}
},
[session, discoveryApi, fetchApi, alertApi],
);
const { state, handleAction } = useConsentSession({ sessionId });
if (!sessionId) {
return (
<Page themeId="tool">
<Header title="Authorization Error" />
<Content>
<EmptyState
missing="data"
title="Invalid Request"
description="The consent request ID is missing or invalid."
/>
</Content>
</Page>
<ConsentPageLayout title="Authorization Error">
<EmptyState
missing="data"
title="Invalid Request"
description="The consent request ID is missing or invalid."
/>
</ConsentPageLayout>
);
}
if (loading) {
if (state.status === 'loading') {
return (
<Page themeId="tool">
<Header title="Authorization Request" />
<Content>
<Box
display="flex"
justifyContent="center"
alignItems="center"
minHeight={300}
>
<Progress />
</Box>
</Content>
</Page>
<ConsentPageLayout title="Authorization Request">
<Box
display="flex"
justifyContent="center"
alignItems="center"
minHeight={300}
>
<Progress />
</Box>
</ConsentPageLayout>
);
}
if (error ?? !session) {
if (state.status === 'error') {
return (
<Page themeId="tool">
<Header title="Authorization Error" />
<Content>
<ResponseErrorPanel
error={new Error(error || 'Authorization request not found')}
/>
</Content>
</Page>
<ConsentPageLayout title="Authorization Error">
<ResponseErrorPanel error={new Error(state.error)} />
</ConsentPageLayout>
);
}
if (completed) {
if (state.status === 'completed') {
return (
<Page themeId="tool">
<Header title="Authorization Complete" />
<Content>
<Card className={classes.authCard}>
<CardContent>
<Box textAlign="center">
{completed.action === 'approve' ? (
<CheckCircleIcon
style={{ fontSize: 64, color: 'green', marginBottom: 16 }}
/>
) : (
<CancelIcon
style={{ fontSize: 64, color: 'red', marginBottom: 16 }}
/>
)}
<Typography variant="h5" gutterBottom>
{completed.action === 'approve'
? 'Authorization Approved'
: 'Authorization Denied'}
</Typography>
<Typography variant="body1" color="textSecondary" gutterBottom>
{completed.action === 'approve'
? 'You have successfully authorized the application to access your Backstage account.'
: 'You have denied the application access to your Backstage account.'}
</Typography>
<Typography variant="body2" color="textSecondary">
Redirecting to the application...
</Typography>
</Box>
</CardContent>
</Card>
</Content>
</Page>
);
}
const appName = session.clientName ?? session.clientId;
return (
<Page themeId="tool">
<Header title="Authorization Request" />
<Content>
<ConsentPageLayout title="Authorization Complete">
<Card className={classes.authCard}>
<CardContent>
<Box className={classes.appHeader}>
<AppsIcon className={classes.appIcon} />
<Box>
<Typography className={classes.appName}>{appName}</Typography>
<Typography variant="body2" color="textSecondary">
wants to access your Backstage account
</Typography>
</Box>
</Box>
<Divider />
<Alert
severity="warning"
icon={<WarningIcon />}
className={classes.securityWarning}
>
<Typography variant="body2">
<strong>Security Notice:</strong> By authorizing this
application, you are granting it access to your Backstage
account. The application will receive an access token that
allows it to act on your behalf.
<Box textAlign="center">
{state.action === 'approve' ? (
<CheckCircleIcon
style={{ fontSize: 64, color: 'green', marginBottom: 16 }}
/>
) : (
<CancelIcon
style={{ fontSize: 64, color: 'red', marginBottom: 16 }}
/>
)}
<Typography variant="h5" gutterBottom>
{state.action === 'approve'
? 'Authorization Approved'
: 'Authorization Denied'}
</Typography>
<Typography variant="body1" color="textSecondary" gutterBottom>
{state.action === 'approve'
? 'You have successfully authorized the application to access your Backstage account.'
: 'You have denied the application access to your Backstage account.'}
</Typography>
<Box mt={1}>
<Typography variant="body2">
<strong>Callback URL:</strong>
</Typography>
<Box className={classes.callbackUrl}>{session.redirectUri}</Box>
</Box>
</Alert>
<Box mt={2}>
<Typography variant="body2" color="textSecondary">
Make sure you trust this application and recognize the callback
URL above. Only authorize applications you trust.
Redirecting to the application...
</Typography>
</Box>
</CardContent>
<CardActions className={classes.buttonContainer}>
<Button
variant="outlined"
color="secondary"
size="large"
disabled={submitting}
onClick={() => handleAction('reject')}
startIcon={<CancelIcon />}
>
Cancel
</Button>
<Button
variant="contained"
color="primary"
size="large"
disabled={submitting}
onClick={() => handleAction('approve')}
startIcon={<CheckCircleIcon />}
>
{submitting ? 'Authorizing...' : 'Authorize'}
</Button>
</CardActions>
</Card>
</Content>
</Page>
</ConsentPageLayout>
);
}
const session = state.session;
const isSubmitting = state.status === 'submitting';
const appName = session.clientName ?? session.clientId;
return (
<ConsentPageLayout title="Authorization Request">
<Card className={classes.authCard}>
<CardContent>
<Box className={classes.appHeader}>
<AppsIcon className={classes.appIcon} />
<Box>
<Typography className={classes.appName}>{appName}</Typography>
<Typography variant="body2" color="textSecondary">
wants to access your Backstage account
</Typography>
</Box>
</Box>
<Divider />
<Alert
severity="warning"
icon={<WarningIcon />}
className={classes.securityWarning}
>
<Typography variant="body2">
<strong>Security Notice:</strong> By authorizing this application,
you are granting it access to your Backstage account. The
application will receive an access token that allows it to act on
your behalf.
</Typography>
<Box mt={1}>
<Typography variant="body2">
<strong>Callback URL:</strong>
</Typography>
<Box className={classes.callbackUrl}>{session.redirectUri}</Box>
</Box>
</Alert>
<Box mt={2}>
<Typography variant="body2" color="textSecondary">
Make sure you trust this application and recognize the callback
URL above. Only authorize applications you trust.
</Typography>
</Box>
</CardContent>
<CardActions className={classes.buttonContainer}>
<Button
variant="outlined"
color="secondary"
size="large"
disabled={isSubmitting}
onClick={() => handleAction('reject')}
startIcon={<CancelIcon />}
>
Cancel
</Button>
<Button
variant="contained"
color="primary"
size="large"
disabled={isSubmitting}
onClick={() => handleAction('approve')}
startIcon={<CheckCircleIcon />}
>
{isSubmitting ? 'Authorizing...' : 'Authorize'}
</Button>
</CardActions>
</Card>
</ConsentPageLayout>
);
};
@@ -0,0 +1,144 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
useApi,
alertApiRef,
fetchApiRef,
discoveryApiRef,
} from '@backstage/frontend-plugin-api';
import { useCallback } from 'react';
import useAsync from 'react-use/esm/useAsync';
import useAsyncFn from 'react-use/esm/useAsyncFn';
import { isError } from '@backstage/errors';
interface Session {
id: string;
clientName?: string;
clientId: string;
redirectUri: string;
scopes?: string[];
responseType?: string;
state?: string;
nonce?: string;
codeChallenge?: string;
codeChallengeMethod?: string;
expiresAt?: string;
}
type ConsentState =
| { status: 'loading' }
| { status: 'error'; error: string }
| { status: 'loaded'; session: Session }
| { status: 'submitting'; session: Session; action: 'approve' | 'reject' }
| { status: 'completed'; action: 'approve' | 'reject' };
export const useConsentSession = (opts: { sessionId?: string }) => {
const alertApi = useApi(alertApiRef);
const fetchApi = useApi(fetchApiRef);
const discoveryApi = useApi(discoveryApiRef);
const { sessionId } = opts;
const sessionState = useAsync(async () => {
if (!sessionId) {
throw new Error('Session ID is missing');
}
const baseUrl = await discoveryApi.getBaseUrl('auth');
const response = await fetchApi.fetch(
`${baseUrl}/v1/sessions/${sessionId}`,
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return (await response.json()) as Session;
}, [sessionId]);
const [actionState, handleActionInternal] = useAsyncFn(
async (action: 'approve' | 'reject', session: Session) => {
const baseUrl = await discoveryApi.getBaseUrl('auth');
const response = await fetchApi.fetch(
`${baseUrl}/v1/sessions/${session.id}/${action}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
if (result.redirectUrl) {
window.location.href = result.redirectUrl;
}
return { action, redirectUrl: result.redirectUrl };
},
[discoveryApi, fetchApi],
);
const getConsentState = (): ConsentState => {
if (actionState.value) {
return { status: 'completed', action: actionState.value.action };
}
if (actionState.loading && sessionState.value) {
return {
status: 'submitting',
session: sessionState.value,
action: 'approve', // This will be set properly when called
};
}
if (sessionState.error) {
return {
status: 'error',
error: isError(sessionState.error)
? sessionState.error.message
: 'Failed to load consent request',
};
}
if (sessionState.value) {
return { status: 'loaded', session: sessionState.value };
}
return { status: 'loading' };
};
const state = getConsentState();
return {
state,
handleAction: useCallback(
async (action: 'approve' | 'reject') => {
if (state.status !== 'loaded') return;
try {
await handleActionInternal(action, state.session);
} catch (err) {
alertApi.post({
message: isError(err) ? err.message : `Failed to ${action} consent`,
severity: 'error',
});
}
},
[state, handleActionInternal, alertApi],
),
};
};
+1 -3
View File
@@ -13,7 +13,6 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { compatWrapper } from '@backstage/core-compat-api';
import {
createFrontendPlugin,
PageBlueprint,
@@ -24,8 +23,7 @@ export const AuthPage = PageBlueprint.make({
params: {
path: '/oauth2',
routeRef: rootRouteRef,
loader: () =>
import('./components/Router').then(m => compatWrapper(<m.Router />)),
loader: () => import('./components/Router').then(m => <m.Router />),
},
});
+16 -80
View File
@@ -4269,10 +4269,7 @@ __metadata:
resolution: "@backstage/plugin-auth@workspace:plugins/auth"
dependencies:
"@backstage/cli": "workspace:^"
"@backstage/core-app-api": "workspace:^"
"@backstage/core-compat-api": "workspace:^"
"@backstage/core-components": "workspace:^"
"@backstage/core-plugin-api": "workspace:^"
"@backstage/dev-utils": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/frontend-defaults": "workspace:^"
@@ -4283,18 +4280,22 @@ __metadata:
"@material-ui/icons": "npm:^4.9.1"
"@material-ui/lab": "npm:4.0.0-alpha.61"
"@testing-library/jest-dom": "npm:^6.0.0"
"@testing-library/react": "npm:^14.0.0"
"@testing-library/react": "npm:^16.0.0"
"@testing-library/user-event": "npm:^14.0.0"
"@types/react": "npm:^18.0.0"
msw: "npm:^1.0.0"
react: "npm:^18.0.2"
react-dom: "npm:^18.0.2"
react-router-dom: "npm:^6.3.0"
react-use: "npm:^17.2.4"
peerDependencies:
"@types/react": ^17.0.0 || ^18.0.0
react: ^17.0.0 || ^18.0.0
react-dom: ^17.0.0 || ^18.0.0
react-router-dom: ^6.3.0
peerDependenciesMeta:
"@types/react":
optional: true
languageName: unknown
linkType: soft
@@ -19521,22 +19522,6 @@ __metadata:
languageName: node
linkType: hard
"@testing-library/dom@npm:^9.0.0":
version: 9.3.4
resolution: "@testing-library/dom@npm:9.3.4"
dependencies:
"@babel/code-frame": "npm:^7.10.4"
"@babel/runtime": "npm:^7.12.5"
"@types/aria-query": "npm:^5.0.1"
aria-query: "npm:5.1.3"
chalk: "npm:^4.1.0"
dom-accessibility-api: "npm:^0.5.9"
lz-string: "npm:^1.5.0"
pretty-format: "npm:^27.0.2"
checksum: 10/510da752ea76f4a10a0a4e3a77917b0302cf03effe576cd3534cab7e796533ee2b0e9fb6fb11b911a1ebd7c70a0bb6f235bf4f816c9b82b95b8fe0cddfd10975
languageName: node
linkType: hard
"@testing-library/jest-dom@npm:6.5.0":
version: 6.5.0
resolution: "@testing-library/jest-dom@npm:6.5.0"
@@ -19589,20 +19574,6 @@ __metadata:
languageName: node
linkType: hard
"@testing-library/react@npm:^14.0.0":
version: 14.3.1
resolution: "@testing-library/react@npm:14.3.1"
dependencies:
"@babel/runtime": "npm:^7.12.5"
"@testing-library/dom": "npm:^9.0.0"
"@types/react-dom": "npm:^18.0.0"
peerDependencies:
react: ^18.0.0
react-dom: ^18.0.0
checksum: 10/83359dcdf9eaf067839f34604e1a181cbc14fc09f3a07672403700fcc6a900c4b8054ad1114fc24b4b9f89d84e2a09e1b7c9afce2306b1d4b4c9e30eb1cb12de
languageName: node
linkType: hard
"@testing-library/react@npm:^16.0.0":
version: 16.3.0
resolution: "@testing-library/react@npm:16.3.0"
@@ -24005,15 +23976,6 @@ __metadata:
languageName: node
linkType: hard
"aria-query@npm:5.1.3":
version: 5.1.3
resolution: "aria-query@npm:5.1.3"
dependencies:
deep-equal: "npm:^2.0.5"
checksum: 10/e5da608a7c4954bfece2d879342b6c218b6b207e2d9e5af270b5e38ef8418f02d122afdc948b68e32649b849a38377785252059090d66fa8081da95d1609c0d2
languageName: node
linkType: hard
"aria-query@npm:5.3.0":
version: 5.3.0
resolution: "aria-query@npm:5.3.0"
@@ -24030,7 +23992,7 @@ __metadata:
languageName: node
linkType: hard
"array-buffer-byte-length@npm:^1.0.0, array-buffer-byte-length@npm:^1.0.1, array-buffer-byte-length@npm:^1.0.2":
"array-buffer-byte-length@npm:^1.0.1, array-buffer-byte-length@npm:^1.0.2":
version: 1.0.2
resolution: "array-buffer-byte-length@npm:1.0.2"
dependencies:
@@ -27790,32 +27752,6 @@ __metadata:
languageName: node
linkType: hard
"deep-equal@npm:^2.0.5":
version: 2.2.3
resolution: "deep-equal@npm:2.2.3"
dependencies:
array-buffer-byte-length: "npm:^1.0.0"
call-bind: "npm:^1.0.5"
es-get-iterator: "npm:^1.1.3"
get-intrinsic: "npm:^1.2.2"
is-arguments: "npm:^1.1.1"
is-array-buffer: "npm:^3.0.2"
is-date-object: "npm:^1.0.5"
is-regex: "npm:^1.1.4"
is-shared-array-buffer: "npm:^1.0.2"
isarray: "npm:^2.0.5"
object-is: "npm:^1.1.5"
object-keys: "npm:^1.1.1"
object.assign: "npm:^4.1.4"
regexp.prototype.flags: "npm:^1.5.1"
side-channel: "npm:^1.0.4"
which-boxed-primitive: "npm:^1.0.2"
which-collection: "npm:^1.0.1"
which-typed-array: "npm:^1.1.13"
checksum: 10/1ce49d0b71d0f14d8ef991a742665eccd488dfc9b3cada069d4d7a86291e591c92d2589c832811dea182b4015736b210acaaebce6184be356c1060d176f5a05f
languageName: node
linkType: hard
"deep-equal@npm:~1.0.1":
version: 1.0.1
resolution: "deep-equal@npm:1.0.1"
@@ -29024,7 +28960,7 @@ __metadata:
languageName: node
linkType: hard
"es-get-iterator@npm:^1.0.2, es-get-iterator@npm:^1.1.3":
"es-get-iterator@npm:^1.0.2":
version: 1.1.3
resolution: "es-get-iterator@npm:1.1.3"
dependencies:
@@ -31621,7 +31557,7 @@ __metadata:
languageName: node
linkType: hard
"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.1, get-intrinsic@npm:^1.2.2, get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.3.0":
"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.1, get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.3.0":
version: 1.3.0
resolution: "get-intrinsic@npm:1.3.0"
dependencies:
@@ -33554,7 +33490,7 @@ __metadata:
languageName: node
linkType: hard
"is-array-buffer@npm:^3.0.2, is-array-buffer@npm:^3.0.4, is-array-buffer@npm:^3.0.5":
"is-array-buffer@npm:^3.0.4, is-array-buffer@npm:^3.0.5":
version: 3.0.5
resolution: "is-array-buffer@npm:3.0.5"
dependencies:
@@ -34027,7 +33963,7 @@ __metadata:
languageName: node
linkType: hard
"is-regex@npm:^1.1.4, is-regex@npm:^1.2.1":
"is-regex@npm:^1.2.1":
version: 1.2.1
resolution: "is-regex@npm:1.2.1"
dependencies:
@@ -34076,7 +34012,7 @@ __metadata:
languageName: node
linkType: hard
"is-shared-array-buffer@npm:^1.0.2, is-shared-array-buffer@npm:^1.0.4":
"is-shared-array-buffer@npm:^1.0.4":
version: 1.0.4
resolution: "is-shared-array-buffer@npm:1.0.4"
dependencies:
@@ -43815,7 +43751,7 @@ __metadata:
languageName: node
linkType: hard
"regexp.prototype.flags@npm:^1.5.1, regexp.prototype.flags@npm:^1.5.3":
"regexp.prototype.flags@npm:^1.5.3":
version: 1.5.4
resolution: "regexp.prototype.flags@npm:1.5.4"
dependencies:
@@ -45319,7 +45255,7 @@ __metadata:
languageName: node
linkType: hard
"side-channel@npm:^1.0.4, side-channel@npm:^1.0.6, side-channel@npm:^1.1.0":
"side-channel@npm:^1.0.6, side-channel@npm:^1.1.0":
version: 1.1.0
resolution: "side-channel@npm:1.1.0"
dependencies:
@@ -49486,7 +49422,7 @@ __metadata:
languageName: node
linkType: hard
"which-boxed-primitive@npm:^1.0.2, which-boxed-primitive@npm:^1.1.0, which-boxed-primitive@npm:^1.1.1":
"which-boxed-primitive@npm:^1.1.0, which-boxed-primitive@npm:^1.1.1":
version: 1.1.1
resolution: "which-boxed-primitive@npm:1.1.1"
dependencies:
@@ -49520,7 +49456,7 @@ __metadata:
languageName: node
linkType: hard
"which-collection@npm:^1.0.1, which-collection@npm:^1.0.2":
"which-collection@npm:^1.0.2":
version: 1.0.2
resolution: "which-collection@npm:1.0.2"
dependencies:
@@ -49542,7 +49478,7 @@ __metadata:
languageName: node
linkType: hard
"which-typed-array@npm:^1.1.13, which-typed-array@npm:^1.1.16, which-typed-array@npm:^1.1.18, which-typed-array@npm:^1.1.2":
"which-typed-array@npm:^1.1.16, which-typed-array@npm:^1.1.18, which-typed-array@npm:^1.1.2":
version: 1.1.19
resolution: "which-typed-array@npm:1.1.19"
dependencies: