Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/internalize-fast-deep-equal-react-core.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@knocklabs/react-core": patch
---

Remove the `fast-deep-equal` dependency in favor of an internal `deepEqual` util.
5 changes: 5 additions & 0 deletions .changeset/internalize-jwt-decode-client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@knocklabs/client": patch
---

Remove the `jwt-decode` dependency in favor of an internal JWT payload decoder.
5 changes: 5 additions & 0 deletions .changeset/react-internalize-runtime-deps.md
Original file line number Diff line number Diff line change
@@ -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`.
1 change: 0 additions & 1 deletion packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion packages/client/src/interfaces.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
76 changes: 76 additions & 0 deletions packages/client/src/jwt/index.ts
Original file line number Diff line number Diff line change
@@ -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 = <T = JwtPayload>(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})`,
);
}
};
3 changes: 1 addition & 2 deletions packages/client/src/knock.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { jwtDecode } from "jwt-decode";

import ApiClient from "./api";
import FeedClient from "./clients/feed";
import MessageClient from "./clients/messages";
Expand All @@ -16,6 +14,7 @@ import {
UserIdOrUserWithProperties,
UserTokenExpiringCallback,
} from "./interfaces";
import { jwtDecode } from "./jwt";

const DEFAULT_HOST = "https://api.knock.app";

Expand Down
84 changes: 84 additions & 0 deletions packages/client/test/jwt.test.ts
Original file line number Diff line number Diff line change
@@ -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, unknown>): 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);
});
});
6 changes: 3 additions & 3 deletions packages/client/test/knock.test.ts
Original file line number Diff line number Diff line change
@@ -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,
})),
Expand Down
1 change: 0 additions & 1 deletion packages/react-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
44 changes: 44 additions & 0 deletions packages/react-core/src/modules/core/deepEqual/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
const objB = b as Record<string, unknown>;

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;
};
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import fastDeepEqual from "fast-deep-equal";
import { useMemo, useRef } from "react";

import { deepEqual } from "../deepEqual";

export default function useStableOptions<T>(options: T): T {
const optionsRef = useRef<T>(undefined);

return useMemo(() => {
const currentOptions = optionsRef.current;

if (currentOptions && fastDeepEqual(options, currentOptions)) {
if (currentOptions && deepEqual(options, currentOptions)) {
return currentOptions;
}

Expand Down
72 changes: 72 additions & 0 deletions packages/react-core/test/core/deepEqual.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
3 changes: 0 additions & 3 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions packages/react/src/modules/core/cx/index.ts
Original file line number Diff line number Diff line change
@@ -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(" ");
Loading
Loading