From fd07c3403fe5b4f6de5fb5e983120e29cd16d0f4 Mon Sep 17 00:00:00 2001
From: rabbitkiller <243249439@qq.com>
Date: Tue, 11 Mar 2025 22:50:55 +0800
Subject: [PATCH] feat(solid): add solid.js logto sdk
---
packages/solid-sample/index.html | 13 +
packages/solid-sample/package.json | 22 ++
packages/solid-sample/public/favicon.ico | Bin 0 -> 664 bytes
packages/solid-sample/src/App.tsx | 34 ++
packages/solid-sample/src/app.css | 4 +
packages/solid-sample/src/consts/index.ts | 7 +
packages/solid-sample/src/index.tsx | 7 +
packages/solid-sample/src/pages/Callback.tsx | 15 +
packages/solid-sample/src/pages/Home.tsx | 77 +++++
.../solid-sample/src/pages/Organizations.tsx | 44 +++
.../src/pages/ProtectedResource.tsx | 27 ++
packages/solid-sample/tsconfig.json | 26 ++
packages/solid-sample/vite.config.ts | 25 ++
packages/solid/package.json | 46 +++
packages/solid/rollup.config.js | 7 +
packages/solid/src/context.tsx | 49 +++
packages/solid/src/hooks/index.test.tsx | 295 ++++++++++++++++++
packages/solid/src/hooks/index.ts | 136 ++++++++
packages/solid/src/index.ts | 30 ++
packages/solid/src/provider.tsx | 61 ++++
packages/solid/tsconfig.json | 26 ++
21 files changed, 951 insertions(+)
create mode 100644 packages/solid-sample/index.html
create mode 100644 packages/solid-sample/package.json
create mode 100644 packages/solid-sample/public/favicon.ico
create mode 100644 packages/solid-sample/src/App.tsx
create mode 100644 packages/solid-sample/src/app.css
create mode 100644 packages/solid-sample/src/consts/index.ts
create mode 100644 packages/solid-sample/src/index.tsx
create mode 100644 packages/solid-sample/src/pages/Callback.tsx
create mode 100644 packages/solid-sample/src/pages/Home.tsx
create mode 100644 packages/solid-sample/src/pages/Organizations.tsx
create mode 100644 packages/solid-sample/src/pages/ProtectedResource.tsx
create mode 100644 packages/solid-sample/tsconfig.json
create mode 100644 packages/solid-sample/vite.config.ts
create mode 100644 packages/solid/package.json
create mode 100644 packages/solid/rollup.config.js
create mode 100644 packages/solid/src/context.tsx
create mode 100644 packages/solid/src/hooks/index.test.tsx
create mode 100644 packages/solid/src/hooks/index.ts
create mode 100644 packages/solid/src/index.ts
create mode 100644 packages/solid/src/provider.tsx
create mode 100644 packages/solid/tsconfig.json
diff --git a/packages/solid-sample/index.html b/packages/solid-sample/index.html
new file mode 100644
index 000000000..41a6dced2
--- /dev/null
+++ b/packages/solid-sample/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Logto Solid Sample
+
+
+
+
+
+
diff --git a/packages/solid-sample/package.json b/packages/solid-sample/package.json
new file mode 100644
index 000000000..16988b328
--- /dev/null
+++ b/packages/solid-sample/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "@logto/solid-sample",
+ "version": "1.0.0",
+ "description": "",
+ "main": "index.js",
+ "scripts": {
+ "start": "vite"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "dependencies": {
+ "@logto/solid": "workspace:^",
+ "solid-js": "^1.9.5",
+ "@solidjs/router": "^0.15.3"
+ },
+ "devDependencies": {
+ "typescript": "^5.3.3",
+ "vite": "^6.0.9",
+ "vite-plugin-solid": "^2.11.2"
+ }
+}
diff --git a/packages/solid-sample/public/favicon.ico b/packages/solid-sample/public/favicon.ico
new file mode 100644
index 0000000000000000000000000000000000000000..fb282da0719ef6ab4c1732df93be6216b0d85520
GIT binary patch
literal 664
zcmV;J0%!e+P)m9ebk1R
zejT~~6f_`?;`cEd!+`7(hw@%%2;?RN8gX-L?z6cM(
zKoG@&w+0}f@Pfvwc+deid)qgE!L$ENKYjViZC_Zcr>L(`2oXUT8f0mRQ(6-=HN_Ai
zeBBEz3WP+1Cw`m!49Wf!MnZzp5bH8VkR~BcJ1s-j90TAS2Yo4j!J|KodxYR%3Numw
zA?gq6e`5@!W~F$_De3yt&uspo&2yLb$(NwcPPI-4LGc!}HdY%jfq@AFs8LiZ4k(p}
zZ!c9o+qbWYs-Mg
zgdyTALzJX&7QXHdI_DPTFL33;w}88{e6Zk)MX0kN{3DX9uz#O_L58&XRH$Nvvu;fO
zf&)7@?C~$z1K<>j0ga$$MIg+5xN;eQ?1-CA=`^Y169@Ab6!vcaNP=hxfKN%@Ly^R*
zK1iv*s1Yl6_dVyz8>ZqYhz6J4|3fQ@2LQeX@^%W(B~8>=MoEmBEGGD1;gHXlpX>!W
ym)!leA2L@`cpb^hy)P75=I!`pBYxP7<2VfQ3j76qLgzIA0000 import("./pages/Home")) },
+ { path: '/callback', component: lazy(() => import("./pages/Callback")) },
+ { path: '/protected', component: lazy(() => import("./pages/ProtectedResource")) },
+ { path: '/protected/organizations', component: lazy(() => import("./pages/Organizations")) },
+];
+
+export function App() {
+ const config: LogtoConfig = {
+ appId,
+ endpoint,
+ scopes: [
+ UserScope.Email,
+ UserScope.Phone,
+ UserScope.CustomData,
+ UserScope.Identities,
+ UserScope.Organizations,
+ ],
+ };
+ return (
+
+
+ {routes}
+
+
+ );
+}
diff --git a/packages/solid-sample/src/app.css b/packages/solid-sample/src/app.css
new file mode 100644
index 000000000..a2e219ad9
--- /dev/null
+++ b/packages/solid-sample/src/app.css
@@ -0,0 +1,4 @@
+body {
+ margin: 0;
+ font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
+}
diff --git a/packages/solid-sample/src/consts/index.ts b/packages/solid-sample/src/consts/index.ts
new file mode 100644
index 000000000..4101d62ec
--- /dev/null
+++ b/packages/solid-sample/src/consts/index.ts
@@ -0,0 +1,7 @@
+export const baseUrl = window.location.origin;
+export const redirectUrl = `${baseUrl}/callback`;
+
+export const appId = 'hi9w1eijf2nlnuz88xjt6'; // Register the sample app in Logto dashboard
+export const endpoint = 'http://localhost:3001'; // Replace with your own Logto endpoint
+// export const resource = "your-resource-identifier"; // Replace with your own API resource identifier
+export const resource = ""; // Replace with your own API resource identifier
diff --git a/packages/solid-sample/src/index.tsx b/packages/solid-sample/src/index.tsx
new file mode 100644
index 000000000..57b81d70e
--- /dev/null
+++ b/packages/solid-sample/src/index.tsx
@@ -0,0 +1,7 @@
+import { render } from "solid-js/web";
+import { App } from "./App";
+
+const root = document.getElementById("app");
+if (root) {
+ render(() => , root);
+}
diff --git a/packages/solid-sample/src/pages/Callback.tsx b/packages/solid-sample/src/pages/Callback.tsx
new file mode 100644
index 000000000..c747f4a61
--- /dev/null
+++ b/packages/solid-sample/src/pages/Callback.tsx
@@ -0,0 +1,15 @@
+import { useHandleSignInCallback } from '@logto/solid';
+import { useNavigate } from '@solidjs/router';
+import { Show } from 'solid-js';
+
+export default function Callback() {
+ const navigate = useNavigate();
+ const { isLoading } = useHandleSignInCallback(() => {
+ console.log("???")
+ navigate('/');
+ });
+
+ return
+ Redirecting...
+
+};
diff --git a/packages/solid-sample/src/pages/Home.tsx b/packages/solid-sample/src/pages/Home.tsx
new file mode 100644
index 000000000..0512b5777
--- /dev/null
+++ b/packages/solid-sample/src/pages/Home.tsx
@@ -0,0 +1,77 @@
+import { useLogto, type UserInfoResponse } from '@logto/solid';
+import { createSignal, Show } from "solid-js";
+import { baseUrl, redirectUrl } from '../consts';
+
+const Home = () => {
+ const {isAuthenticated, signIn, signOut, fetchUserInfo} = useLogto();
+ const [user, setUser] = createSignal();
+
+ (async () => {
+ if (isAuthenticated()) {
+ const userInfo = await fetchUserInfo();
+ setUser(userInfo);
+ }
+ })();
+
+ return (
+
+
Logto Solid sample
+
+
+
+
+
+
+
+
+
+
+
+ | Name |
+ Value |
+
+
+
+ {Object.entries(user()).map(([key, value]) => (
+
+ | {key} |
+ {typeof value === 'string' ? value : JSON.stringify(value)} |
+
+ ))}
+
+
+
+
+
+ );
+};
+
+export default Home;
diff --git a/packages/solid-sample/src/pages/Organizations.tsx b/packages/solid-sample/src/pages/Organizations.tsx
new file mode 100644
index 000000000..7ea6bdb13
--- /dev/null
+++ b/packages/solid-sample/src/pages/Organizations.tsx
@@ -0,0 +1,44 @@
+import { useLogto } from '@logto/solid';
+import { createSignal, onMount } from 'solid-js';
+
+const Organizations = () => {
+ const { getOrganizationToken, getOrganizationTokenClaims, getIdTokenClaims } = useLogto();
+ const [organizationIds, setOrganizationIds] = createSignal();
+
+ onMount(() => {
+ (async () => {
+ const claims = await getIdTokenClaims();
+
+ console.log('ID token claims', claims);
+ setOrganizationIds(claims?.organizations);
+ })();
+ });
+
+ return (
+
+ Organizations
+ Go back
+ {organizationIds()?.length === 0 && No organization memberships found.
}
+
+ {organizationIds()?.map((organizationId) => {
+ return (
+ -
+ {organizationId}
+
+
+ );
+ })}
+
+
+ );
+};
+
+export default Organizations;
diff --git a/packages/solid-sample/src/pages/ProtectedResource.tsx b/packages/solid-sample/src/pages/ProtectedResource.tsx
new file mode 100644
index 000000000..92bb90145
--- /dev/null
+++ b/packages/solid-sample/src/pages/ProtectedResource.tsx
@@ -0,0 +1,27 @@
+import { useLogto } from "@logto/solid";
+import { createSignal, Show } from "solid-js";
+import { resource } from "../consts";
+
+const ProtectedResource = () => {
+ const { isAuthenticated, isLoading, signIn, getAccessToken } = useLogto();
+ const [accessToken, setAccessToken] = createSignal();
+ const handleClick = async () => {
+ const token = await getAccessToken(resource);
+ setAccessToken(token);
+ };
+ return (
+
+ Go back
+
+ Protected resource is only visible after sign-in.
+
+
+
+ Access token: {accessToken()}
+
+
+)
+ ;
+};
+
+export default ProtectedResource;
diff --git a/packages/solid-sample/tsconfig.json b/packages/solid-sample/tsconfig.json
new file mode 100644
index 000000000..7430ee9f7
--- /dev/null
+++ b/packages/solid-sample/tsconfig.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "target": "esnext",
+ "esModuleInterop": true,
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "esnext"
+ ],
+ "jsx": "preserve",
+ "jsxImportSource": "solid-js",
+ "strict": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "skipLibCheck": true,
+ "noEmit": false,
+ "outDir": "lib",
+ "types": ["solid-js"]
+ },
+ "include": [
+ "src",
+ "vitest.config.ts"
+ ]
+}
diff --git a/packages/solid-sample/vite.config.ts b/packages/solid-sample/vite.config.ts
new file mode 100644
index 000000000..52606f451
--- /dev/null
+++ b/packages/solid-sample/vite.config.ts
@@ -0,0 +1,25 @@
+import { fileURLToPath, URL } from "url";
+
+import { defineConfig } from "vite";
+import solid from "vite-plugin-solid";
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [solid()],
+ resolve: {
+ alias: {
+ "@": fileURLToPath(new URL("./src", import.meta.url)),
+ },
+ },
+ optimizeDeps: {
+ include: ["@logto/solid"],
+ },
+ build: {
+ // commonjsOptions: {
+ // include: [/vue/, /node_modules/],
+ // },
+ },
+ server: {
+ port: 3000,
+ },
+});
diff --git a/packages/solid/package.json b/packages/solid/package.json
new file mode 100644
index 000000000..90e393f4f
--- /dev/null
+++ b/packages/solid/package.json
@@ -0,0 +1,46 @@
+{
+ "name": "@logto/solid",
+ "version": "0.0.0",
+ "type": "module",
+ "module": "./dist/esm/index.js",
+ "types": "./dist/types/index.d.ts",
+ "exports": {
+ "types": "./dist/types/index.d.ts",
+ "import": "./dist/esm/index.js",
+ "default": "./dist/source/index.js"
+ },
+ "files": [
+ "dist"
+ ],
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/logto-io/js.git",
+ "directory": "packages/solid"
+ },
+ "scripts": {
+ "dev:tsc": "tsc -p tsconfig.build.json -w --preserveWatchOutput",
+ "precommit": "lint-staged",
+ "check": "tsc --noEmit",
+ "build": "rm -rf lib/ && tsc -p tsconfig.build.json --noEmit && rollup -c",
+ "lint": "eslint --ext .ts --ext .tsx src",
+ "test": "vitest",
+ "test:coverage": "vitest --silent --coverage",
+ "prepack": "pnpm build && pnpm test"
+ },
+ "dependencies": {
+ "@logto/browser": "workspace:^",
+ "@silverhand/essentials": "^2.9.2",
+ "solid-js": "^1.9.5"
+ },
+ "devDependencies": {
+ "rollup-preset-solid": "^3.0.0",
+ "typescript": "^5.3.3"
+ },
+ "peerDependencies": {
+ "solid-js": ">=1.9.0"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/packages/solid/rollup.config.js b/packages/solid/rollup.config.js
new file mode 100644
index 000000000..8f397aec2
--- /dev/null
+++ b/packages/solid/rollup.config.js
@@ -0,0 +1,7 @@
+import withSolid from "rollup-preset-solid";
+
+export default withSolid({
+ input: "src/index.ts",
+ targets: ["esm"],
+});
+
diff --git a/packages/solid/src/context.tsx b/packages/solid/src/context.tsx
new file mode 100644
index 000000000..22de2adc0
--- /dev/null
+++ b/packages/solid/src/context.tsx
@@ -0,0 +1,49 @@
+import type LogtoClient from '@logto/browser';
+import {Accessor, createContext} from 'solid-js';
+
+export type LogtoContextProps = {
+ /** The underlying LogtoClient instance (from `@logto/browser`). */
+ logtoClient: Accessor;
+ /** Whether the user is authenticated or not. */
+ isAuthenticated: Accessor;
+ /** Whether the context has any pending requests. It will be `true` if there is at least one request pending. */
+ isLoading: Accessor;
+ /** The error that occurred during the last request. If there was no error, this will be `undefined`. */
+ error: Accessor;
+ /** Sets the authentication state. */
+ setIsAuthenticated: (isAuthenticated: boolean) => void;
+ /**
+ * Sets the loading state.
+ *
+ * @remarks
+ * Instead of directly setting the boolean value, this function will increment or decrement the
+ * loading count.
+ *
+ * - If the `state` is `true`, the loading count will be incremented by 1.
+ * - If the `state` is `false`, the loading count will be decremented by 1. If the loading count
+ * is already 0, it will be set to 0.
+ */
+ setLoading: (state: boolean) => void;
+ /** Sets the error state. To clear the error, set this to `undefined`. */
+ setError: (error: unknown, fallbackErrorMessage?: string | undefined) => void;
+};
+
+export const throwContextError = (): never => {
+ throw new Error('Must be used inside context.');
+};
+
+/**
+ * The context for the LogtoProvider.
+ *
+ * @remarks
+ * Instead of using this context directly, in most cases you should use the `useLogto` hook.
+ */
+export const LogtoContext = createContext({
+ logtoClient: () => undefined,
+ isAuthenticated: () => false,
+ isLoading: () => false,
+ error: () => undefined,
+ setIsAuthenticated: throwContextError,
+ setLoading: throwContextError,
+ setError: throwContextError,
+});
diff --git a/packages/solid/src/hooks/index.test.tsx b/packages/solid/src/hooks/index.test.tsx
new file mode 100644
index 000000000..b07163cb0
--- /dev/null
+++ b/packages/solid/src/hooks/index.test.tsx
@@ -0,0 +1,295 @@
+import LogtoClient from '@logto/browser';
+import { renderHook, act, waitFor } from '@testing-library/react';
+import { useContext, type ReactNode, useEffect } from 'react';
+
+import { LogtoContext } from '../context.js';
+import { LogtoProvider } from '../provider.js';
+
+import { useHandleSignInCallback, useLogto } from './index.js';
+
+const isAuthenticated = vi.fn(async () => false);
+const isSignInRedirected = vi.fn(async () => false);
+const handleSignInCallback = vi.fn().mockResolvedValue(undefined);
+const getAccessToken = vi.fn();
+const signIn = vi.fn();
+
+vi.mock('@logto/browser', () => {
+ return {
+ default: vi.fn().mockImplementation(() => {
+ return {
+ isAuthenticated,
+ isSignInRedirected,
+ handleSignInCallback,
+ getRefreshToken: vi.fn(),
+ getAccessToken,
+ getAccessTokenClaims: vi.fn(),
+ getOrganizationToken: vi.fn(),
+ getOrganizationTokenClaims: vi.fn(),
+ getIdToken: vi.fn(),
+ getIdTokenClaims: vi.fn(),
+ signIn,
+ signOut: vi.fn(),
+ fetchUserInfo: vi.fn(),
+ clearAccessToken: vi.fn(),
+ clearAllTokens: vi.fn(),
+ } satisfies Partial;
+ }),
+ };
+});
+
+const endpoint = 'https://logto.dev';
+const appId = 'foo';
+
+const createHookWrapper =
+ () =>
+ ({ children }: { children?: ReactNode }) => (
+ {children}
+ );
+
+const HasError = ({ children }: { children?: ReactNode }) => {
+ const { error, setError } = useContext(LogtoContext);
+ useEffect(() => {
+ setError(new Error('Oops'));
+ }, [setError]);
+
+ // eslint-disable-next-line react/jsx-no-useless-fragment
+ return <>{error ? children : null}>;
+};
+
+const AlwaysLoading = ({ children }: { children?: ReactNode }) => {
+ const { setIsLoading } = useContext(LogtoContext);
+ useEffect(() => {
+ setIsLoading(true); // Simulate always loading
+ }, [setIsLoading]);
+
+ // eslint-disable-next-line react/jsx-no-useless-fragment
+ return <>{children}>;
+};
+
+describe('useLogto', () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ handleSignInCallback.mockRestore();
+ });
+
+ it('should throw without using context provider', () => {
+ expect(() => renderHook(useLogto)).toThrow();
+ });
+
+ it('should call LogtoClient constructor on init', async () => {
+ await act(async () => {
+ renderHook(useLogto, {
+ wrapper: createHookWrapper(),
+ });
+ });
+
+ expect(LogtoClient).toHaveBeenCalledWith({ endpoint, appId }, false);
+ });
+
+ it('should return LogtoClient property methods', async () => {
+ const { result } = renderHook(useLogto, {
+ wrapper: createHookWrapper(),
+ });
+
+ await waitFor(() => {
+ const {
+ signIn,
+ signOut,
+ fetchUserInfo,
+ getAccessToken,
+ getIdTokenClaims,
+ clearAccessToken,
+ clearAllTokens,
+ error,
+ } = result.current;
+
+ expect(error).toBeUndefined();
+ expect(signIn).toBeDefined();
+ expect(signOut).toBeDefined();
+ expect(fetchUserInfo).toBeDefined();
+ expect(getAccessToken).toBeDefined();
+ expect(getIdTokenClaims).toBeDefined();
+ expect(clearAccessToken).toBeDefined();
+ expect(clearAllTokens).toBeDefined();
+ });
+ });
+
+ it('should not call `handleSignInCallback` when logtoClient is not found in the context', async () => {
+ // Mock `isSignInRedirected` to return true for triggering `useEffect`
+ isSignInRedirected.mockResolvedValueOnce(true);
+ const { result } = renderHook(useHandleSignInCallback);
+
+ await waitFor(() => {
+ // LogtoClient is initialized
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.isAuthenticated).toBe(false);
+ });
+
+ expect(handleSignInCallback).not.toHaveBeenCalled();
+ isSignInRedirected.mockRestore();
+ });
+
+ it('should not call `handleSignInCallback` when it is not in callback url', async () => {
+ const { result } = renderHook(useHandleSignInCallback, {
+ wrapper: createHookWrapper(),
+ });
+
+ await waitFor(() => {
+ // LogtoClient is initialized
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.isAuthenticated).toBe(false);
+ });
+
+ expect(handleSignInCallback).not.toHaveBeenCalled();
+ });
+
+ it('should not call `handleSignInCallback` when it is authenticated', async () => {
+ isSignInRedirected.mockResolvedValueOnce(true);
+ isAuthenticated.mockResolvedValueOnce(true);
+
+ await act(async () => {
+ renderHook(useHandleSignInCallback, {
+ wrapper: createHookWrapper(),
+ });
+ });
+
+ expect(handleSignInCallback).not.toHaveBeenCalled();
+ });
+
+ it('should call `handleSignInCallback` when navigated back to predefined callback url', async () => {
+ isSignInRedirected.mockResolvedValueOnce(true);
+
+ const { result } = renderHook(useHandleSignInCallback, {
+ wrapper: createHookWrapper(),
+ });
+ await waitFor(() => {
+ expect(result.current.isAuthenticated).toBe(true);
+ });
+ expect(handleSignInCallback).toHaveBeenCalledTimes(1);
+ });
+
+ it('should call `handleSignInCallback` only once even if it fails internally', async () => {
+ isSignInRedirected.mockResolvedValueOnce(true);
+ handleSignInCallback.mockRejectedValueOnce(new Error('Oops'));
+
+ const { result } = renderHook(useHandleSignInCallback, {
+ wrapper: createHookWrapper(),
+ });
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.isAuthenticated).toBe(false);
+ });
+ expect(handleSignInCallback).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not call `handleSignInCallback` when it is loading', async () => {
+ isSignInRedirected.mockResolvedValueOnce(true);
+
+ const { result } = renderHook(useHandleSignInCallback, {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(true);
+ });
+
+ // Give some time for side effects to be triggered
+ await new Promise((resolve) => {
+ setTimeout(resolve, 1);
+ });
+ expect(handleSignInCallback).toHaveBeenCalledTimes(0);
+ });
+
+ it('should not call `handleSignInCallback` when `useLogto` has error', async () => {
+ const { result } = renderHook(useHandleSignInCallback, {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ });
+
+ await waitFor(() => {
+ expect(result.current.error).toBeDefined();
+ });
+ // Give some time for side effects to be triggered
+ await new Promise((resolve) => {
+ setTimeout(resolve, 1);
+ });
+ expect(handleSignInCallback).toHaveBeenCalledTimes(0);
+ });
+
+ it('should return error when getAccessToken fails', async () => {
+ const { result } = renderHook(useLogto, {
+ wrapper: createHookWrapper(),
+ });
+
+ await act(async () => {
+ getAccessToken.mockRejectedValueOnce(new Error('not authenticated'));
+ await result.current.getAccessToken();
+ });
+ await waitFor(() => {
+ expect(result.current.error).not.toBeUndefined();
+ expect(result.current.error?.message).toBe('not authenticated');
+ expect(result.current.isLoading).toBe(false);
+ });
+ });
+
+ it('should use fallback error message when getAccessToken fails', async () => {
+ const { result } = renderHook(useLogto, {
+ wrapper: createHookWrapper(),
+ });
+
+ await act(async () => {
+ getAccessToken.mockRejectedValueOnce('not authenticated');
+ await result.current.getAccessToken('foo');
+ });
+ await waitFor(() => {
+ expect(result.current.error).not.toBeUndefined();
+ expect(result.current.error?.message).toBe(
+ 'Unexpected error occurred while calling bound spy.'
+ );
+ expect(result.current.isLoading).toBe(false);
+ });
+ });
+
+ it('should call the inner LogtoClient method when the hook method is called', async () => {
+ const { result } = renderHook(useLogto, {
+ wrapper: createHookWrapper(),
+ });
+
+ await act(async () => {
+ await result.current.signIn('foo');
+ });
+
+ await waitFor(() => {
+ expect(signIn).toHaveBeenCalledTimes(1);
+ expect(signIn).toHaveBeenCalledWith('foo');
+ expect(result.current.error).toBeUndefined();
+ // `signIn` disables resetting loading state
+ expect(result.current.isLoading).toBe(true);
+ });
+ });
+
+ it('should be able to call the inner LogtoClient method overload signature', async () => {
+ const { result } = renderHook(useLogto, {
+ wrapper: createHookWrapper(),
+ });
+
+ await act(async () => {
+ await result.current.signIn({ redirectUri: 'foo' });
+ });
+
+ await waitFor(() => {
+ expect(signIn).toHaveBeenCalledTimes(1);
+ expect(signIn).toHaveBeenCalledWith({ redirectUri: 'foo' });
+ expect(result.current.error).toBeUndefined();
+ // `signIn` disables resetting loading state
+ expect(result.current.isLoading).toBe(true);
+ });
+ });
+});
diff --git a/packages/solid/src/hooks/index.ts b/packages/solid/src/hooks/index.ts
new file mode 100644
index 000000000..df10b7f9e
--- /dev/null
+++ b/packages/solid/src/hooks/index.ts
@@ -0,0 +1,136 @@
+import type LogtoClient from '@logto/browser';
+import { type Optional, trySafe } from '@silverhand/essentials';
+import { Accessor, createEffect, useContext } from 'solid-js';
+
+import { LogtoContext, throwContextError } from '../context';
+
+type OptionalPromiseReturn = {
+ [K in keyof T]: T[K] extends (...args: infer A) => Promise
+ ? (...args: A) => Promise>
+ : T[K];
+};
+
+type Logto = {
+ isAuthenticated: Accessor;
+ isLoading: Accessor;
+ error: Accessor;
+} & OptionalPromiseReturn<
+ Pick<
+ LogtoClient,
+ | 'getRefreshToken'
+ | 'getAccessToken'
+ | 'getAccessTokenClaims'
+ | 'getOrganizationToken'
+ | 'getOrganizationTokenClaims'
+ | 'getIdToken'
+ | 'getIdTokenClaims'
+ | 'signOut'
+ | 'fetchUserInfo'
+ | 'clearAccessToken'
+ | 'clearAllTokens'
+ >
+> &
+ // Manually pick the method with overloads since TypeScript cannot infer the correct type.
+ Pick;
+
+const useErrorHandler = () => {
+ const {setError} = useContext(LogtoContext);
+
+ function handleError(error: unknown, fallbackErrorMessage?: string) {
+ if (error instanceof Error) {
+ setError(error);
+ } else if (fallbackErrorMessage) {
+ setError(new Error(fallbackErrorMessage));
+ }
+ console.error(error);
+ }
+
+ return {handleError};
+};
+
+const useHandleSignInCallback = (callback?: () => void) => {
+ const {logtoClient, isAuthenticated, error, setIsAuthenticated, isLoading, setLoading, setError} =
+ useContext(LogtoContext);
+
+ createEffect(() => {
+ const client = logtoClient();
+ if (!client || isLoading() || error()) {
+ return;
+ }
+
+ (async () => {
+ const currentPageUrl = window.location.href;
+ const isRedirected = await client.isSignInRedirected(currentPageUrl);
+
+ if (!isAuthenticated() && isRedirected) {
+ setLoading(true);
+ await trySafe(
+ async () => {
+ await client.handleSignInCallback(currentPageUrl);
+ setIsAuthenticated(true);
+ callback?.();
+ },
+ (error) => {
+ setError(error, 'Unexpected error occurred while handling sign in callback.');
+ }
+ );
+ setLoading(false);
+ }
+ })();
+ })
+
+ return {
+ isLoading,
+ isAuthenticated,
+ error,
+ };
+};
+
+const useLogto = (): Logto => {
+ const {logtoClient, isAuthenticated, error, isLoading, setLoading, setError} = useContext(LogtoContext);
+
+ const client = logtoClient() ?? throwContextError();
+
+ const proxy = (
+ run: (...args: T) => Promise,
+ resetLoadingState = true
+ ) => {
+ return async (...args: T): Promise> => {
+ try {
+ setLoading(true);
+ return await run(...args);
+ } catch (error: unknown) {
+ setError(error, `Unexpected error occurred while calling ${run.name}.`);
+ } finally {
+ if (resetLoadingState) {
+ setLoading(false);
+ }
+ }
+ };
+ };
+
+ return {
+ isAuthenticated,
+ isLoading,
+ error,
+ getRefreshToken: proxy(client.getRefreshToken.bind(client)),
+ getAccessToken: proxy(client.getAccessToken.bind(client)),
+ getAccessTokenClaims: proxy(client.getAccessTokenClaims.bind(client)),
+ getOrganizationToken: proxy(client.getOrganizationToken.bind(client)),
+ getOrganizationTokenClaims: proxy(client.getOrganizationTokenClaims.bind(client)),
+ getIdToken: proxy(client.getIdToken.bind(client)),
+ getIdTokenClaims: proxy(client.getIdTokenClaims.bind(client)),
+ // eslint-disable-next-line no-restricted-syntax -- TypeScript cannot infer the correct type.
+ signIn: proxy(client.signIn.bind(client), false) as LogtoClient['signIn'],
+ // We deliberately do NOT set isAuthenticated to false in the function below, because the app state
+ // may change immediately even before navigating to the oidc end session endpoint, which might cause
+ // rendering problems.
+ // Moreover, since the location will be redirected, the isAuthenticated state will not matter any more.
+ signOut: proxy(client.signOut.bind(client)),
+ fetchUserInfo: proxy(client.fetchUserInfo.bind(client)),
+ clearAccessToken: proxy(client.clearAccessToken.bind(client)),
+ clearAllTokens: proxy(client.clearAllTokens.bind(client)),
+ };
+};
+
+export { useLogto, useHandleSignInCallback };
diff --git a/packages/solid/src/index.ts b/packages/solid/src/index.ts
new file mode 100644
index 000000000..43d7bcdf8
--- /dev/null
+++ b/packages/solid/src/index.ts
@@ -0,0 +1,30 @@
+export type { LogtoContextProps, LogtoContext } from './context.js';
+
+export type {
+ LogtoConfig,
+ IdTokenClaims,
+ UserInfoResponse,
+ LogtoErrorCode,
+ LogtoClientErrorCode,
+ InteractionMode,
+ AccessTokenClaims,
+} from '@logto/browser';
+
+export {
+ LogtoError,
+ LogtoRequestError,
+ LogtoClientError,
+ OidcError,
+ Prompt,
+ ReservedScope,
+ ReservedResource,
+ UserScope,
+ organizationUrnPrefix,
+ buildOrganizationUrn,
+ getOrganizationIdFromUrn,
+ PersistKey,
+} from '@logto/browser';
+
+export * from './provider';
+
+export { useLogto, useHandleSignInCallback } from './hooks/index';
diff --git a/packages/solid/src/provider.tsx b/packages/solid/src/provider.tsx
new file mode 100644
index 000000000..2b4b29140
--- /dev/null
+++ b/packages/solid/src/provider.tsx
@@ -0,0 +1,61 @@
+import LogtoClient, { type LogtoConfig } from '@logto/browser';
+import { createMemo, createSignal, JSX } from "solid-js";
+import { LogtoContext } from './context';
+
+export type LogtoProviderProps = {
+ config: LogtoConfig;
+ /**
+ * Whether to enable cache for well-known data. Use sessionStorage by default.
+ * @default false
+ */
+ // eslint-disable-next-line react/boolean-prop-naming
+ unstable_enableCache?: boolean;
+ LogtoClientClass?: typeof LogtoClient;
+ children: JSX.Element;
+};
+
+export const LogtoProvider = (props: LogtoProviderProps) => {
+ const LogtoClientClass = props.LogtoClientClass || LogtoClient;
+ const [loadingCount, setLoadingCount] = createSignal(1);
+
+ const logtoClient = createMemo(() => {
+ return new LogtoClientClass(props.config, props.unstable_enableCache)
+ })
+ const [isAuthenticated, setIsAuthenticated] = createSignal(false);
+ const [error, setError] = createSignal();
+
+ const isLoading = () => {
+ return loadingCount() > 0;
+ }
+ const setLoading = (isLoading: boolean) => {
+ if (isLoading) {
+ setLoadingCount(loadingCount() + 1);
+ } else {
+ setLoadingCount(Math.max(0, loadingCount() - 1));
+ }
+ };
+
+ (async () => {
+ const isAuthenticated = await logtoClient().isAuthenticated();
+
+ setIsAuthenticated(isAuthenticated);
+ setLoading(false);
+ })();
+
+ return {
+ if (_error instanceof Error) {
+ setError(_error);
+ } else if (fallbackErrorMessage) {
+ setError(new Error(fallbackErrorMessage));
+ }
+ console.error(error);
+ },
+ }}>{props.children};
+};
diff --git a/packages/solid/tsconfig.json b/packages/solid/tsconfig.json
new file mode 100644
index 000000000..7430ee9f7
--- /dev/null
+++ b/packages/solid/tsconfig.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "target": "esnext",
+ "esModuleInterop": true,
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "esnext"
+ ],
+ "jsx": "preserve",
+ "jsxImportSource": "solid-js",
+ "strict": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "skipLibCheck": true,
+ "noEmit": false,
+ "outDir": "lib",
+ "types": ["solid-js"]
+ },
+ "include": [
+ "src",
+ "vitest.config.ts"
+ ]
+}