diff --git a/.changeset/internalize-fast-deep-equal-react-core.md b/.changeset/internalize-fast-deep-equal-react-core.md new file mode 100644 index 000000000..0078e313d --- /dev/null +++ b/.changeset/internalize-fast-deep-equal-react-core.md @@ -0,0 +1,5 @@ +--- +"@knocklabs/react-core": patch +--- + +Remove the `fast-deep-equal` dependency in favor of an internal `deepEqual` util. diff --git a/.changeset/internalize-jwt-decode-client.md b/.changeset/internalize-jwt-decode-client.md new file mode 100644 index 000000000..3d0e6b860 --- /dev/null +++ b/.changeset/internalize-jwt-decode-client.md @@ -0,0 +1,5 @@ +--- +"@knocklabs/client": patch +--- + +Remove the `jwt-decode` dependency in favor of an internal JWT payload decoder. diff --git a/.changeset/react-internalize-runtime-deps.md b/.changeset/react-internalize-runtime-deps.md new file mode 100644 index 000000000..e463ed386 --- /dev/null +++ b/.changeset/react-internalize-runtime-deps.md @@ -0,0 +1,5 @@ +--- +"@knocklabs/react": patch +--- + +Remove the `clsx` and `lodash.debounce` dependencies. Guide components compose `className` with a small internal `cx` helper, and an internal trailing-edge debounce replaces `lodash.debounce`. diff --git a/packages/client/package.json b/packages/client/package.json index 8809dea23..d3311d524 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -75,7 +75,6 @@ "axios": "^1.15.1", "axios-retry": "^4.5.0", "eventemitter2": "^6.4.5", - "jwt-decode": "^4.0.0", "nanoid": "^3.3.12", "phoenix": "1.8.5", "urlpattern-polyfill": "^10.0.0" diff --git a/packages/client/src/interfaces.ts b/packages/client/src/interfaces.ts index 43baccad3..fdf065ab0 100644 --- a/packages/client/src/interfaces.ts +++ b/packages/client/src/interfaces.ts @@ -1,6 +1,6 @@ import { GenericData } from "@knocklabs/types"; -import { JwtPayload } from "jwt-decode"; +import { JwtPayload } from "./jwt"; import Knock from "./knock"; export type LogLevel = "debug"; diff --git a/packages/client/src/jwt/index.ts b/packages/client/src/jwt/index.ts new file mode 100644 index 000000000..e8a8f9ad5 --- /dev/null +++ b/packages/client/src/jwt/index.ts @@ -0,0 +1,76 @@ +/** + * Decodes a JWT payload by base64url-decoding the second segment and parsing + * it as JSON. This does not verify the token signature. + */ + +export type JwtPayload = { + iss?: string; + sub?: string; + aud?: string | string[]; + exp?: number; + nbf?: number; + iat?: number; + jti?: string; +}; + +export class InvalidTokenError extends Error {} +InvalidTokenError.prototype.name = "InvalidTokenError"; + +const b64DecodeUnicode = (str: string): string => { + return decodeURIComponent( + atob(str).replace(/(.)/g, (char) => { + const code = char.charCodeAt(0).toString(16).toUpperCase(); + return "%" + code.padStart(2, "0"); + }), + ); +}; + +const base64UrlDecode = (str: string): string => { + const base64 = str.replace(/-/g, "+").replace(/_/g, "/"); + const remainder = base64.length % 4; + if (remainder === 1) { + throw new Error("base64 string is not of the correct length"); + } + const padded = base64 + "=".repeat((4 - remainder) % 4); + + try { + return b64DecodeUnicode(padded); + } catch { + return atob(padded); + } +}; + +const decodePayloadSegment = (part: string): string => { + try { + return base64UrlDecode(part); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + throw new InvalidTokenError( + `Invalid token specified: invalid base64 for part #2 (${message})`, + ); + } +}; + +export const jwtDecode = (token: string): T => { + if (typeof token !== "string") { + throw new InvalidTokenError("Invalid token specified: must be a string"); + } + + // The payload is the second segment of the JWT (header.payload.signature). + const part = token.split(".")[1]; + + if (typeof part !== "string") { + throw new InvalidTokenError("Invalid token specified: missing part #2"); + } + + const decoded = decodePayloadSegment(part); + + try { + return JSON.parse(decoded) as T; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + throw new InvalidTokenError( + `Invalid token specified: invalid json for part #2 (${message})`, + ); + } +}; diff --git a/packages/client/src/knock.ts b/packages/client/src/knock.ts index d1f4648eb..06d881da4 100644 --- a/packages/client/src/knock.ts +++ b/packages/client/src/knock.ts @@ -1,5 +1,3 @@ -import { jwtDecode } from "jwt-decode"; - import ApiClient from "./api"; import FeedClient from "./clients/feed"; import MessageClient from "./clients/messages"; @@ -16,6 +14,7 @@ import { UserIdOrUserWithProperties, UserTokenExpiringCallback, } from "./interfaces"; +import { jwtDecode } from "./jwt"; const DEFAULT_HOST = "https://api.knock.app"; diff --git a/packages/client/test/jwt.test.ts b/packages/client/test/jwt.test.ts new file mode 100644 index 000000000..921d6d058 --- /dev/null +++ b/packages/client/test/jwt.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "vitest"; + +import { InvalidTokenError, jwtDecode } from "../src/jwt"; + +// Builds a JWT-shaped string for the given payload. Only the payload segment +// is meaningful to `jwtDecode`; the header and signature are placeholders. +const encodeToken = (payload: Record): string => { + const header = Buffer.from( + JSON.stringify({ alg: "HS256", typ: "JWT" }), + ).toString("base64url"); + const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); + return `${header}.${body}.signature`; +}; + +describe("jwtDecode", () => { + test("decodes standard registered claims", () => { + const token = encodeToken({ + sub: "user_123", + exp: 9999999999, + iat: 1516239022, + }); + + const decoded = jwtDecode(token); + + expect(decoded.sub).toBe("user_123"); + expect(decoded.exp).toBe(9999999999); + expect(decoded.iat).toBe(1516239022); + }); + + test("decodes custom claims via a type parameter", () => { + const token = encodeToken({ sub: "user_123", role: "admin" }); + + const decoded = jwtDecode<{ sub: string; role: string }>(token); + + expect(decoded).toEqual({ sub: "user_123", role: "admin" }); + }); + + test("decodes payloads containing unicode characters", () => { + const token = encodeToken({ name: "José Ünïcode 🚀" }); + + const decoded = jwtDecode<{ name: string }>(token); + + expect(decoded.name).toBe("José Ünïcode 🚀"); + }); + + // Vary payload length to exercise each `length % 4` padding branch. + test.each(["a", "ab", "abc", "abcd"])( + "decodes a base64url payload of length %s (padding)", + (id) => { + const decoded = jwtDecode<{ id: string }>(encodeToken({ id })); + expect(decoded.id).toBe(id); + }, + ); + + test("throws InvalidTokenError when the token is not a string", () => { + // @ts-expect-error - exercising the runtime guard against non-string input + expect(() => jwtDecode(undefined)).toThrow(InvalidTokenError); + }); + + test("throws InvalidTokenError when the payload segment is missing", () => { + expect(() => jwtDecode("only-one-segment")).toThrow(InvalidTokenError); + }); + + test("throws InvalidTokenError when the payload is not valid JSON", () => { + const notJson = Buffer.from("not json").toString("base64url"); + + expect(() => jwtDecode(`header.${notJson}.signature`)).toThrow( + InvalidTokenError, + ); + }); + + test("throws InvalidTokenError when the payload has an invalid base64 length", () => { + // A 5-character segment can never be valid base64 (length % 4 === 1). + expect(() => jwtDecode("header.abcde.signature")).toThrow( + InvalidTokenError, + ); + }); + + test("falls back to raw base64 for payloads that are not valid UTF-8", () => { + // "_w" base64url-decodes to the byte 0xFF (invalid UTF-8), so the decoder + // falls back to atob; the decoded value is not valid JSON, so it throws. + expect(() => jwtDecode("header._w.signature")).toThrow(InvalidTokenError); + }); +}); diff --git a/packages/client/test/knock.test.ts b/packages/client/test/knock.test.ts index 39d6f7f55..dd5660346 100644 --- a/packages/client/test/knock.test.ts +++ b/packages/client/test/knock.test.ts @@ -1,15 +1,15 @@ -import { jwtDecode } from "jwt-decode"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { jwtDecode } from "../src/jwt"; import Knock from "../src/knock"; import { authenticateKnock, createMockKnock } from "./test-utils/mocks"; const TEST_BRANCH_SLUG = "lorem-ipsum-dolor-branch"; -// ✅ Mock the named export `jwtDecode` from the "jwt-decode" module. +// ✅ Mock the named export `jwtDecode` from the internal jwt module. // It will always return a decoded token with an `exp` 61 seconds in the future. -vi.mock("jwt-decode", () => ({ +vi.mock("../src/jwt", () => ({ jwtDecode: vi.fn(() => ({ exp: Math.floor(Date.now() / 1000) + 61, })), diff --git a/packages/react-core/package.json b/packages/react-core/package.json index 7e723480e..8250da578 100644 --- a/packages/react-core/package.json +++ b/packages/react-core/package.json @@ -50,7 +50,6 @@ "@knocklabs/client": "workspace:^", "@tanstack/react-store": "^0.7.3", "date-fns": "^4.0.0", - "fast-deep-equal": "^3.1.3", "swr": "^2.4.1" }, "devDependencies": { diff --git a/packages/react-core/src/modules/core/deepEqual/index.ts b/packages/react-core/src/modules/core/deepEqual/index.ts new file mode 100644 index 000000000..dd44c17e1 --- /dev/null +++ b/packages/react-core/src/modules/core/deepEqual/index.ts @@ -0,0 +1,44 @@ +/** + * Deep structural equality check. Recursively compares primitives, plain + * objects, arrays, `Date`, and `RegExp` values. Does not special-case Map/Set. + */ +export const deepEqual = (a: unknown, b: unknown): boolean => { + if (a === b) return true; + + if (a && b && typeof a === "object" && typeof b === "object") { + const objA = a as Record; + const objB = b as Record; + + if (objA.constructor !== objB.constructor) return false; + + if (Array.isArray(a) && Array.isArray(b)) { + return ( + a.length === b.length && a.every((value, i) => deepEqual(value, b[i])) + ); + } + + if (a.constructor === RegExp) { + const reA = a as RegExp; + const reB = b as RegExp; + return reA.source === reB.source && reA.flags === reB.flags; + } + if (objA.valueOf !== Object.prototype.valueOf) { + return objA.valueOf() === objB.valueOf(); + } + if (objA.toString !== Object.prototype.toString) { + return objA.toString() === objB.toString(); + } + + const keys = Object.keys(objA); + if (keys.length !== Object.keys(objB).length) return false; + + return keys.every( + (key) => + Object.prototype.hasOwnProperty.call(objB, key) && + deepEqual(objA[key], objB[key]), + ); + } + + // true if both NaN, false otherwise + return a !== a && b !== b; +}; diff --git a/packages/react-core/src/modules/core/hooks/useStableOptions.ts b/packages/react-core/src/modules/core/hooks/useStableOptions.ts index 8645db605..9db760ee5 100644 --- a/packages/react-core/src/modules/core/hooks/useStableOptions.ts +++ b/packages/react-core/src/modules/core/hooks/useStableOptions.ts @@ -1,13 +1,14 @@ -import fastDeepEqual from "fast-deep-equal"; import { useMemo, useRef } from "react"; +import { deepEqual } from "../deepEqual"; + export default function useStableOptions(options: T): T { const optionsRef = useRef(undefined); return useMemo(() => { const currentOptions = optionsRef.current; - if (currentOptions && fastDeepEqual(options, currentOptions)) { + if (currentOptions && deepEqual(options, currentOptions)) { return currentOptions; } diff --git a/packages/react-core/test/core/deepEqual.test.ts b/packages/react-core/test/core/deepEqual.test.ts new file mode 100644 index 000000000..6a157198a --- /dev/null +++ b/packages/react-core/test/core/deepEqual.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "vitest"; + +import { deepEqual } from "../../src/modules/core/deepEqual"; + +describe("deepEqual", () => { + test("treats identical references and equal primitives as equal", () => { + const obj = { a: 1 }; + expect(deepEqual(obj, obj)).toBe(true); + expect(deepEqual(1, 1)).toBe(true); + expect(deepEqual("a", "a")).toBe(true); + expect(deepEqual(true, true)).toBe(true); + expect(deepEqual(null, null)).toBe(true); + expect(deepEqual(undefined, undefined)).toBe(true); + }); + + test("distinguishes differing primitives", () => { + expect(deepEqual(1, 2)).toBe(false); + expect(deepEqual("a", "b")).toBe(false); + expect(deepEqual(null, undefined)).toBe(false); + expect(deepEqual(0, false)).toBe(false); + }); + + test("treats NaN as equal to NaN", () => { + expect(deepEqual(NaN, NaN)).toBe(true); + }); + + test("compares nested objects structurally", () => { + expect(deepEqual({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } })).toBe(true); + expect(deepEqual({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 3 } })).toBe(false); + }); + + test("is independent of object key order", () => { + expect(deepEqual({ a: 1, b: 2 }, { b: 2, a: 1 })).toBe(true); + }); + + test("distinguishes objects with differing key counts", () => { + expect(deepEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false); + expect(deepEqual({ a: 1, b: 2 }, { a: 1 })).toBe(false); + }); + + test("compares arrays by length and element", () => { + expect(deepEqual([1, 2, 3], [1, 2, 3])).toBe(true); + expect(deepEqual([1, 2], [1, 2, 3])).toBe(false); + expect(deepEqual([{ a: 1 }], [{ a: 1 }])).toBe(true); + }); + + test("does not treat an array and an object as equal", () => { + expect(deepEqual([], {})).toBe(false); + }); + + test("compares Date values by time", () => { + expect(deepEqual(new Date("2020-01-01"), new Date("2020-01-01"))).toBe( + true, + ); + expect(deepEqual(new Date("2020-01-01"), new Date("2021-01-01"))).toBe( + false, + ); + }); + + test("compares RegExp values by source and flags", () => { + expect(deepEqual(/abc/gi, /abc/gi)).toBe(true); + expect(deepEqual(/abc/g, /abc/i)).toBe(false); + expect(deepEqual(/abc/, /abd/)).toBe(false); + }); + + test("compares objects with a custom toString but default valueOf", () => { + const tag = (label: string) => ({ toString: () => label }); + + expect(deepEqual(tag("x"), tag("x"))).toBe(true); + expect(deepEqual(tag("x"), tag("y"))).toBe(false); + }); +}); diff --git a/packages/react/package.json b/packages/react/package.json index 89cc3a97a..1c9887c55 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -88,8 +88,6 @@ "@telegraph/tokens": ">=0.2.0", "@telegraph/tooltip": ">=0.2.2", "@telegraph/typography": ">=0.4.0", - "clsx": "^2.1.1", - "lodash.debounce": "^4.0.8", "lucide-react": "^0.544.0" }, "devDependencies": { @@ -99,7 +97,6 @@ "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", "@types/eslint-plugin-jsx-a11y": "^6", - "@types/lodash.debounce": "^4.0.9", "@types/react": "^19.2.14", "@types/react-dom": "^19.1.6", "@typescript-eslint/eslint-plugin": "^8.59.4", diff --git a/packages/react/src/modules/core/cx/index.ts b/packages/react/src/modules/core/cx/index.ts new file mode 100644 index 000000000..5342a706f --- /dev/null +++ b/packages/react/src/modules/core/cx/index.ts @@ -0,0 +1,9 @@ +type ClassValue = string | false | null | undefined; + +/** + * Joins truthy class names into a space-separated string, e.g. + * `cx("knock-guide-banner", className)`. Falsy values are dropped, so + * conditional modifiers work too: `cx("btn", isActive && "btn--active")`. + */ +export const cx = (...classes: ClassValue[]): string => + classes.filter((value): value is string => Boolean(value)).join(" "); diff --git a/packages/react/src/modules/core/debounce/index.ts b/packages/react/src/modules/core/debounce/index.ts new file mode 100644 index 000000000..38127612e --- /dev/null +++ b/packages/react/src/modules/core/debounce/index.ts @@ -0,0 +1,35 @@ +type DebouncedFunction = { + (...args: TArgs): void; + cancel: () => void; +}; + +/** + * Minimal trailing-edge debounce, replacing `lodash.debounce` for our single + * use case (no `leading`/`maxWait` options). Invokes `func` once `wait` + * milliseconds have elapsed since the last call. + */ +export const debounce = ( + func: (...args: TArgs) => void, + wait = 0, +): DebouncedFunction => { + let timeout: ReturnType | null = null; + + const debounced = (...args: TArgs) => { + if (timeout !== null) { + clearTimeout(timeout); + } + timeout = setTimeout(() => { + timeout = null; + func(...args); + }, wait); + }; + + debounced.cancel = () => { + if (timeout !== null) { + clearTimeout(timeout); + timeout = null; + } + }; + + return debounced; +}; diff --git a/packages/react/src/modules/core/hooks/useOnBottomScroll.ts b/packages/react/src/modules/core/hooks/useOnBottomScroll.ts index 3e5d3e9ef..d10bd53dd 100644 --- a/packages/react/src/modules/core/hooks/useOnBottomScroll.ts +++ b/packages/react/src/modules/core/hooks/useOnBottomScroll.ts @@ -1,6 +1,7 @@ -import debounce from "lodash.debounce"; import { RefObject, useCallback, useEffect, useMemo } from "react"; +import { debounce } from "../debounce"; + type OnBottomScrollOptions = { ref: RefObject; callback: () => void; diff --git a/packages/react/src/modules/guide/components/Banner/Banner.tsx b/packages/react/src/modules/guide/components/Banner/Banner.tsx index bb951fbc3..e8c46cd41 100644 --- a/packages/react/src/modules/guide/components/Banner/Banner.tsx +++ b/packages/react/src/modules/guide/components/Banner/Banner.tsx @@ -1,7 +1,7 @@ import { ColorMode, useGuide } from "@knocklabs/react-core"; -import clsx from "clsx"; import React from "react"; +import { cx } from "../../../core/cx"; import { maybeNavigateToUrlWithDelay } from "../helpers"; import { ButtonContent, TargetButton, TargetButtonWithGuide } from "../types"; @@ -13,7 +13,7 @@ const Root: React.FC< React.PropsWithChildren> > = ({ children, className, ...props }) => { return ( -
+
{children}
); @@ -24,7 +24,7 @@ const Content: React.FC< React.PropsWithChildren> > = ({ children, className, ...props }) => { return ( -
+
{children}
); @@ -35,7 +35,7 @@ const Title: React.FC< { title: string } & React.ComponentPropsWithRef<"div"> > = ({ title, className, ...props }) => { return ( -
+
{title}
); @@ -49,7 +49,7 @@ const Body: React.FC<{ body: string } & React.ComponentPropsWithRef<"div">> = ({ }) => { return (
@@ -61,7 +61,7 @@ const Actions: React.FC< React.PropsWithChildren> > = ({ children, className, ...props }) => { return ( -
+
{children}
); @@ -72,10 +72,7 @@ const PrimaryButton: React.FC< ButtonContent & React.ComponentPropsWithRef<"button"> > = ({ text, action, className, ...props }) => { return ( - ); @@ -87,7 +84,7 @@ const SecondaryButton: React.FC< > = ({ text, action, className, ...props }) => { return ( ); @@ -128,7 +128,7 @@ const SecondaryButton: React.FC< > = ({ text, action, className, ...props }) => { return ( ); @@ -153,7 +153,7 @@ const SecondaryButton: React.FC< > = ({ text, action, className, ...props }) => { return (