diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be09d18..ed9462c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,7 +74,7 @@ jobs: - name: Build run: bun run build - - uses: actions/upload-artifact@v2 + - uses: actions/upload-artifact@v4 with: name: build path: dist @@ -85,6 +85,7 @@ jobs: needs: [lint, test, build] steps: - uses: actions/checkout@v4 + - run: git --version - uses: oven-sh/setup-bun@v1 with: bun-version: latest @@ -92,13 +93,13 @@ jobs: - name: Install Packages run: bun install --frozen-lockfile - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 with: name: build path: dist - name: Deploy - run: bunx semantic-release + run: bunx semantic-release@^22 env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.swcrc b/.swcrc new file mode 100644 index 0000000..32589e1 --- /dev/null +++ b/.swcrc @@ -0,0 +1,16 @@ +{ + "$schema": "https://json.schemastore.org/swcrc", + "sourceMaps": "inline", + "jsc": { + "target": "es2022", + "parser": { + "syntax": "typescript", + "tsx": true + }, + "transform": { + "react": { + "runtime": "automatic" + } + } + } +} diff --git a/README.md b/README.md index 18204fb..64d0129 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,22 @@ export function MySimpleInput({ className: externalClassName, value: externalVal } ``` +## `useSubtleCrypto` + +```tsx +import { useSubtleCrypto } from '@rain-cafe/react-utils'; + +export type ProfileProps = { + email?: string; +}; + +export function Profile({ email }: ProfileProps) { + const hashedEmail = useSubtleCrypto('SHA-256', email); + + return ; +} +``` + [_**Want to Contribute?**_](/CONTRIBUTING.md) [npm-version-image]: https://img.shields.io/npm/v/@rain-cafe/react-utils.svg diff --git a/bun.lockb b/bun.lockb index 67fe63d..923a35e 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/jest.config.ts b/jest.config.ts index b6fc5c0..27cb5f1 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,23 +1,19 @@ -import type { JestConfigWithTsJest } from 'ts-jest'; +import type { Config } from 'jest'; +import { readFileSync } from 'fs'; -const jestConfig: JestConfigWithTsJest = { +// Changed sourcemaps to inline resolving issues with https://github.com/swc-project/swc/issues/3854 +const config = JSON.parse(readFileSync(`${__dirname}/.swcrc`, 'utf-8')); + +export default { roots: ['/src'], - testEnvironment: 'jsdom', + testEnvironment: '@happy-dom/jest-environment', transform: { - '^.+\\.tsx?$': [ - 'ts-jest', - { - tsconfig: 'tsconfig.json', - }, - ], + '^.+\\.(t|j)sx?$': ['@swc/jest', config], }, collectCoverageFrom: ['/src/**/*'], - coveragePathIgnorePatterns: ['__tests__'], - - setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect'], -}; - -export default jestConfig; + setupFilesAfterEnv: ['@inrupt/jest-jsdom-polyfills', '@testing-library/jest-dom/extend-expect'], + extensionsToTreatAsEsm: ['.ts', '.tsx'], +} satisfies Config; diff --git a/package.json b/package.json index 44f3e7a..15a4786 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,9 @@ "classnames": "^2.5.1" }, "devDependencies": { + "@happy-dom/jest-environment": "^13.0.6", + "@inrupt/jest-jsdom-polyfills": "^3.0.2", + "@swc/jest": "^0.2.29", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.4.3", @@ -39,7 +42,6 @@ "eslint-plugin-react": "^7.33.1", "eslint-plugin-unused-imports": "^3.0.0", "jest": "^29.6.2", - "jest-environment-jsdom": "^29.6.2", "microbundle": "^0.15.1", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/src/hooks/__tests__/use-subtle-crypto.spec.tsx b/src/hooks/__tests__/use-subtle-crypto.spec.tsx new file mode 100644 index 0000000..5a05abb --- /dev/null +++ b/src/hooks/__tests__/use-subtle-crypto.spec.tsx @@ -0,0 +1,29 @@ +import { render, waitFor } from '@testing-library/react'; +import Chance from 'chance'; +import { useSubtleCrypto } from '../use-subtle-crypto'; +import { hash } from '../../utils/subtle-crypto'; + +const chance = new Chance(); + +describe('Crypto Hooks', () => { + describe('hook(useSubtleCrypto)', () => { + type ExampleComponentProps = { + value: string; + }; + + function ExampleComponent({ value }: ExampleComponentProps) { + const hashedValue = useSubtleCrypto('SHA-256', value); + + return
{hashedValue}
; + } + + it('should cache the value', async () => { + const value = chance.string(); + const expectedValue = await hash('SHA-256', value); + + const component = render(); + + await waitFor(() => expect(component.getByText(expectedValue)).toBeTruthy()); + }); + }); +}); diff --git a/src/hooks/use-subtle-crypto.ts b/src/hooks/use-subtle-crypto.ts new file mode 100644 index 0000000..1e38da8 --- /dev/null +++ b/src/hooks/use-subtle-crypto.ts @@ -0,0 +1,19 @@ +import { useEffect, useState } from 'react'; +import { Algorithms, hash } from '../utils/subtle-crypto'; + +/** + * Returns a hashed version of the value provided + * @param algorithm the hashing algorithm to use + * @param value the value to hash + * @returns the hashed value + * @see https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest#browser_compatibility + */ +export function useSubtleCrypto(algorithm: Algorithms, value?: string | null): string | undefined { + const [hashedValue, setHashedValue] = useState(); + + useEffect(() => { + hash(algorithm, value).then(setHashedValue); + }, [algorithm, value]); + + return hashedValue; +} diff --git a/src/utils/__tests__/subtle-crypto.spec.tsx b/src/utils/__tests__/subtle-crypto.spec.tsx new file mode 100644 index 0000000..a0d9d4a --- /dev/null +++ b/src/utils/__tests__/subtle-crypto.spec.tsx @@ -0,0 +1,25 @@ +import { Algorithms, hash } from '../../utils/subtle-crypto'; + +describe('Crypto Utils', () => { + describe('fn(hash)', () => { + it.each([ + ['SHA-1', 'a94a8fe5ccb19ba61c4c0873d391e987982fbbd3'], + ['SHA-256', '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08'], + ['SHA-384', '768412320f7b0aa5812fce428dc4706b3cae50e02a64caa16a782249bfe8efc4b7ef1ccb126255d196047dfedf17a0a9'], + [ + 'SHA-512', + 'ee26b0dd4af7e749aa1a8ee3c10ae9923f618980772e473f8819a5d4940e0db27ac185f8a0e1d5f84f88bc887fd67b143732c304cc5fa9ad8e6f57f50028a8ff', + ], + ])('should support %s', async (algorithm: Algorithms, expectedValue: string) => { + await expect(hash(algorithm, 'test')).resolves.toEqual(expectedValue); + }); + + it('should support being provided null', async () => { + await expect(hash('SHA-256', null)).resolves.toEqual(null); + }); + + it('should support being provided undefined', async () => { + await expect(hash('SHA-256', undefined)).resolves.toEqual(null); + }); + }); +}); diff --git a/src/utils/subtle-crypto.ts b/src/utils/subtle-crypto.ts new file mode 100644 index 0000000..5e5afc8 --- /dev/null +++ b/src/utils/subtle-crypto.ts @@ -0,0 +1,16 @@ +export type Algorithms = 'SHA-1' | 'SHA-256' | 'SHA-384' | 'SHA-512'; + +export async function hash(algorithm: Algorithms, message?: string): Promise { + if (!message) return null; + + const msgBuffer = new TextEncoder().encode(message); + + // hash the message + const hashBuffer = await crypto.subtle.digest(algorithm, msgBuffer); + + // convert ArrayBuffer to Array + const hashArray = Array.from(new Uint8Array(hashBuffer)); + + // convert bytes to hex string + return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); +}