diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index a920cd6..af91e4e 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -25,3 +25,5 @@ jobs:
run: bun run lint
- name: Format Check
run: bun run format
+ - name: Run Tests
+ run: bun run test
diff --git a/README.md b/README.md
index 1344212..ee5841a 100644
--- a/README.md
+++ b/README.md
@@ -1,58 +1,132 @@
-# Template: SolidJS Library
+
+
+
-Template for [SolidJS](https://www.solidjs.com/) library package. Bundling of the library is managed by [tsup](https://tsup.egoist.dev/).
+[![NPM Version](https://img.shields.io/npm/v/solid-plaid-link.svg?style=for-the-badge)](https://www.npmjs.com/package/solid-plaid-link) [![Build Status](https://img.shields.io/github/actions/workflow/status/thedanchez/solid-plaid-link/ci.yaml?branch=main&logo=github&style=for-the-badge)](https://github.com/thedanchez/solid-plaid-link/actions/workflows/ci.yaml) [![bun](https://img.shields.io/badge/maintained%20with-bun-cc00ff.svg?style=for-the-badge&logo=bun)](https://bun.sh/)
-Other things configured include:
+# Solid Plaid Link
-- Bun (for dependency management and running scripts)
-- TypeScript
-- ESLint / Prettier
-- Solid Testing Library + Vitest (for testing)
-- Playground app using library
-- GitHub Actions (for all CI/CD)
+Library for integrating with [Plaid Link](https://plaid.com/docs/link/) in your SolidJS applications.
-## Getting Started
+_Note: This is an unofficial Solid fork based on the official [react-plaid-link](https://github.com/plaid/react-plaid-link) library._
-Some pre-requisites before install dependencies:
-
-- Install Node Version Manager (NVM)
- ```bash
- curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
- ```
-- Install Bun
- ```bash
- curl -fsSL https://bun.sh/install | bash
- ```
-
-### Installing Dependencies
+### Installation
```bash
-nvm use
-bun install
+npm install solid-js @danchez/solid-plaid-link
+pnpm add solid-js @danchez/solid-plaid-link
+yarn add solid-js @danchez/solid-plaid-link
+bun add solid-js @danchez/solid-plaid-link
```
-### Local Development Build
-
-```bash
-bun start
-```
+## Summary
-### Linting & Formatting
+This library exports three things:
-```bash
-bun run lint # checks source for lint violations
-bun run format # checks source for format violations
+```tsx
+import { createPlaidLink, PlaidLink, PlaidEmbeddedLink } from "@danchez/solid-plaid-link";
+```
-bun run lint:fix # fixes lint violations
-bun run format:fix # fixes format violations
+### createPlaidLink
+
+The main core of the library -- this hook does all the heavy lifting for `solid-plaid-link`. It is responsibile for dynamically loading the Plaid script and managing the Plaid Link lifecycle for you. It takes care of refreshing the link token used to initialize Plaid Link before it expires (after 4 hours) and destroying the Plaid Link UI when unmounting on your behalf. Use this hook when you want full control over how and when to display the Plaid Link UI.
+
+- _Note: A new Plaid Link instance is created any time the configuration props change._
+
+In order to initialize Plaid Link via this hook, a [link token](https://plaid.com/docs/api/link/#linktokencreate) is required from Plaid. You can fetch the link token from your server via the required `fetchToken` field. Once Link has been initialized, it returns a temporary `publicToken`. This `publicToken` must then be exchanged for a permanent `accessToken` which is used to make product requests.
+
+- _Note: the `publicToken` to `accessToken` exchange should be handled by your API server that fulfills requests for your SPA._
+
+```tsx
+import { createEffect, Match, onMount, Switch } from "solid-js";
+import { createPlaidLink } from "@danchez/solid-plaid-link";
+
+const ExampleOne = () => {
+ const { ready, error, plaidLink } = createPlaidLink(() => ({
+ fetchToken: async () => {
+ const response = await fetch("https://api.example.com/plaid/link-token");
+ const { link_token, expiration } = await response.json();
+ return { link_token, expiration };
+ },
+ onLoad: () => { ... },
+ onSuccess: (publicToken, metaData) => { ... },
+ onEvent: (eventName, metaData) => { ... },
+ onExit: (error, metaData) => { ... },
+ }));
+
+ return (
+
+ {error().message}
+
+ { /* use if you just need a button :) */ }
+ { plaidLink().open(); }}>
+ Open Plaid Link
+
+
+
+ );
+};
+
+const ExampleTwo = () => {
+ const { ready, plaidLink } = createPlaidLink(() => ({
+ fetchToken: async () => {
+ const response = await fetch("https://api.example.com/plaid/link-token");
+ const { link_token, expiration } = await response.json();
+ return { link_token, expiration };
+ },
+ onLoad: () => { ... },
+ onSuccess: (publicToken, metaData) => { ... },
+ onEvent: (eventName, metaData) => { ... },
+ onExit: (error, metaData) => { ... },
+ }));
+
+ createEffect(() => {
+ if (!ready()) return;
+ plaidLink().open();
+ });
+
+ return (...);
+};
```
-### Contributing
+### PlaidLink / PlaidEmbeddedLink
+
+This library also provides two Solid components as a convenience: ` ` and ` ` .
+
+` ` is a button which opens the Plaid Link UI on click. If there are any issues downloading Plaid or creating the Plaid Link instance, the button will be disabled. It is built on top of `createPlaidLink` so it accepts all the same configuration fields along with all the `ButtonHTMLAttributes` as props so you are free to customize the button with your own styles. Additionally, you can enrich the `disabled` or `onClick` props with your own logic on top of their underlying default behaviors.
+
+` ` is a component that renders the embedded version of the Plaid Link UI using a `div` container. It accepts the same Plaid configuration options as `PlaidLink` but it is **not** built on top of `createPlaidLink` as `PlaidLink` is since the underlying `Plaid.createEmbedded` API works a bit differently than the `Plaid.create` API.
+
+One thing to note about the aforementioned Solid components above, specifically with regard to `fetchToken`: in Solid, JSX is a reactively tracked scope and the reactivity system in Solid only tracks **_synchronously_** -- therefore, components cannot receive `async` functions as values. To workaround this, the function passed to `fetchToken` for the Plaid Link JSX components must use promise chaining instead of relying on `async/await` syntax.
+
+```tsx
+import { PlaidLink, PlaidEmbeddedLink } from "@danchez/solid-plaid-link";
+
+const ComponentA = () => (
+ fetch("https://api.example.com/plaid/link-token").then((response) => response.json())}
+ onLoad={() => { ... }}
+ onLoadError={(error) => { ... }}
+ onSuccess={(publicToken, metaData) => { ... }}
+ onEvent={(eventName, metaData) => { ... }}
+ onExit={(error, metaData) => { ... }}
+ >
+ Open Plaid
+
+);
+
+const ComponentB = () => (
+ fetch("https://api.example.com/plaid/link-token").then((response) => response.json())}
+ onLoad={() => { ... }}
+ onLoadError={(error) => { ... }}
+ onSuccess={(publicToken, metaData) => { ... }}
+ onEvent={(eventName, metaData) => { ... }}
+ onExit={(error, metaData) => { ... }}
+ />
+);
+```
-The only requirements when contributing are:
+## Feedback
-- You keep a clean git history in your branch
- - rebasing `main` instead of making merge commits.
-- Using proper commit message formats that adhere to [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/)
- - Additionally, squashing (via rebase) commits that are not [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/)
-- CI checks pass before merging into `main`
+Feel free to post any issues or suggestions to help improve this library.
diff --git a/bun.lockb b/bun.lockb
index c25dc32..f9ef24e 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/package.json b/package.json
index 2086d4f..2a25013 100644
--- a/package.json
+++ b/package.json
@@ -1,14 +1,36 @@
{
- "name": "solid-plaid-link",
- "version": "0.1.0",
- "description": "Solid bindings for Plaid Link",
+ "name": "@danchez/solid-plaid-link",
+ "version": "1.0.0",
+ "description": "Library for integrating Plaid Link into SolidJS applications.",
"type": "module",
"author": "Daniel Sanchez ",
+ "main": "./dist/index.js",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "browser": {},
+ "exports": {
+ "solid": "./dist/index.jsx",
+ "import": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ }
+ },
"license": "MIT",
"homepage": "https://github.com/thedanchez/solid-plaid-link#readme",
"bugs": {
"url": "https://github.com/thedanchez/solid-plaid-link/issues"
},
+ "files": [
+ "dist",
+ "README.md"
+ ],
+ "keywords": [
+ "link",
+ "plaid",
+ "plaid-link",
+ "solid",
+ "solid-plaid"
+ ],
"scripts": {
"build": "tsup",
"build:watch": "tsup --watch",
@@ -26,6 +48,7 @@
"devDependencies": {
"@solidjs/testing-library": "^0.8.10",
"@testing-library/jest-dom": "^6.5.0",
+ "@testing-library/user-event": "^14.5.2",
"@types/bun": "^1.1.10",
"@typescript-eslint/eslint-plugin": "^8.7.0",
"@typescript-eslint/parser": "^8.7.0",
@@ -35,6 +58,7 @@
"eslint-plugin-solid": "^0.14.3",
"jsdom": "^25.0.1",
"prettier": "^3.3.3",
+ "solid-js": "1.9.3",
"tsup": "^8.3.0",
"tsup-preset-solid": "^2.2.0",
"typescript": "^5.6.2",
@@ -44,5 +68,9 @@
},
"peerDependencies": {
"solid-js": ">=1.8.0"
- }
+ },
+ "dependencies": {
+ "solid-create-script": "^1.0.0"
+ },
+ "typesVersions": {}
}
diff --git a/playground/App.tsx b/playground/App.tsx
index c587cc9..e37f10e 100644
--- a/playground/App.tsx
+++ b/playground/App.tsx
@@ -1,19 +1,44 @@
-import { createSignal } from "solid-js";
+import { createScript } from "solid-create-script";
+import { createEffect, createSignal, Show } from "solid-js";
+
+import { Second } from "./Second";
+import { Third } from "./Third";
export const App = () => {
- const [count, setCount] = createSignal(0);
+ const [showSecond, setShowSecond] = createSignal(false);
+ const [showThird, setShowThird] = createSignal(false);
+
+ const script = createScript("https://cdn.plaid.com/link/v2/stable/link-initialize.js", { defer: true });
+
+ createEffect(() => {
+ console.log("Script Error State: ", script.error.message);
+ });
return (
Playground App
-
Count: {count()}
+
Script Loading State: {script.loading.toString()}
+
Script Error: {script.error?.message}
+
{
+ setShowSecond((prev) => !prev);
+ }}
+ >
+ Toggle Second
+
{
- setCount((prev) => prev + 1);
+ setShowThird((prev) => !prev);
}}
>
- Increment Count
+ Toggle Third
+
+
+
+
+
+
);
};
diff --git a/playground/Second.tsx b/playground/Second.tsx
new file mode 100644
index 0000000..cf026ce
--- /dev/null
+++ b/playground/Second.tsx
@@ -0,0 +1,12 @@
+import { createScript } from "solid-create-script";
+
+export const Second = () => {
+ const script = createScript("https://cdn.plaid.com/link/v2/stable/link-initialize.js", { defer: true });
+
+ return (
+
+
Second Component
+
Script Loading State: {script.loading.toString()}
+
+ );
+};
diff --git a/playground/Third.tsx b/playground/Third.tsx
new file mode 100644
index 0000000..6849eb3
--- /dev/null
+++ b/playground/Third.tsx
@@ -0,0 +1,12 @@
+import { createScript } from "solid-create-script";
+
+export const Third = () => {
+ const script = createScript("https://cdn.plaid.com/link/v2/stable/link-initialize.js", { defer: true });
+
+ return (
+
+
Third Component
+
Script Loading State: {script.loading.toString()}
+
+ );
+};
diff --git a/src/components/PlaidEmbeddedLink.tsx b/src/components/PlaidEmbeddedLink.tsx
new file mode 100644
index 0000000..c18c79c
--- /dev/null
+++ b/src/components/PlaidEmbeddedLink.tsx
@@ -0,0 +1,88 @@
+import { createScript } from "solid-create-script";
+import { type Accessor, createEffect, createResource, type JSX, onCleanup, splitProps } from "solid-js";
+
+import { PLAID_LINK_PROPS, PLAID_LINK_STABLE_URL } from "../constants";
+import type { CreatePlaidLinkConfig } from "../types";
+
+export type PlaidEmbeddedLinkProps = CreatePlaidLinkConfig &
+ JSX.HTMLAttributes & {
+ readonly onLoadError?: (error: Error) => void;
+ };
+
+/**
+ * ## Summary
+ *
+ * Solid component that renders the embedded version of the Plaid Link UI using a `div` container.
+ *
+ * ## Usage
+ *
+ * ```tsx
+ * import { PlaidEmbeddedLink } from "@danchez/solid-plaid-link";
+ *
+ * const MyComponent = () => (
+ * fetch("https://api.example.com/plaid/link-token").then((response) => response.json())}
+ * onLoad={() => { ... }}
+ * onLoadError={(error) => { ... }}
+ * onSuccess={(publicToken, metaData) => { ... }}
+ * onEvent={(eventName, metaData) => { ... }}
+ * onExit={(error, metaData) => { ... }}
+ * />
+ * );
+ */
+const PlaidEmbeddedLink = (props: PlaidEmbeddedLinkProps) => {
+ let embeddedLinkTarget!: HTMLDivElement;
+ const [plaidLinkProps, divProps] = splitProps(props, PLAID_LINK_PROPS);
+ const [tokenProps, plaidProps] = splitProps(plaidLinkProps, ["fetchToken"]);
+
+ const script = createScript(PLAID_LINK_STABLE_URL, {
+ defer: true,
+ });
+
+ const [tokenRequest, { refetch }] = createResource(() => tokenProps.fetchToken());
+ const error: Accessor = () => script.error || tokenRequest.error || null;
+
+ createEffect(() => {
+ if (!error()) return;
+ props.onLoadError?.(error() as Error);
+ });
+
+ createEffect(() => {
+ if (script.loading || tokenRequest.loading) return;
+
+ if (script.error || typeof window === "undefined" || !window.Plaid) {
+ console.error("solid-plaid-link: Error loading Plaid script.", script.error?.message);
+ return;
+ }
+
+ const { link_token, expiration } = tokenRequest() ?? { link_token: "", expiration: "" };
+
+ if (!link_token || !expiration) {
+ console.error("solid-plaid-link: fetchToken response missing link token or expiration date.");
+ return;
+ }
+
+ const plaidConfig = { ...plaidProps, token: link_token };
+
+ // The embedded Link interface doesn't use the `createPlaidLink` hook to manage
+ // its Plaid Link instance because the embedded Link integration in link-initialize
+ // maintains its own handler internally.
+ const { destroy } = window.Plaid.createEmbedded(plaidConfig, embeddedLinkTarget);
+
+ const now = Date.now();
+ const expirationTime = new Date(expiration).getTime();
+ const timeUntilLinkTokenExpires = expirationTime - now;
+
+ // Link Tokens expire after 4 hours. Set a timer to refresh the token 5 minutes before it does.
+ const timerId = setTimeout(refetch, timeUntilLinkTokenExpires - 5 * 60 * 1000);
+
+ onCleanup(() => {
+ clearTimeout(timerId);
+ destroy();
+ });
+ });
+
+ return
;
+};
+
+export default PlaidEmbeddedLink;
diff --git a/src/components/PlaidLink.tsx b/src/components/PlaidLink.tsx
new file mode 100644
index 0000000..7db9190
--- /dev/null
+++ b/src/components/PlaidLink.tsx
@@ -0,0 +1,76 @@
+import { createEffect, type JSX, type ParentProps, splitProps } from "solid-js";
+
+import { PLAID_LINK_PROPS } from "../constants";
+import createPlaidLink from "../hooks/createPlaidLink";
+import type { CreatePlaidLinkConfig } from "../types";
+
+type PlaidLinkProps = CreatePlaidLinkConfig &
+ JSX.ButtonHTMLAttributes & {
+ readonly onReady?: () => void;
+ readonly onLoadError?: (error: Error) => void;
+ };
+
+/**
+ * ## Summary
+ *
+ * A button which opens the Plaid Link UI on click. If there are any issues downloading Plaid or creating the Plaid Link
+ * instance, the button will be disabled.
+ *
+ * It is built on top of `createPlaidLink` so it accepts all the same configuration fields along with all the
+ * `ButtonHTMLAttributes` as props so you are free to customize the button with your own styles. Additionally, you can
+ * enrich the `disabled` or `onClick` with your own logic alongside the default behaviors if you so choose.
+ *
+ * One thing to note with respect to `fetchToken`: in Solid, JSX is a reactively tracked scope and the reactivity system
+ * in Solid only tracks ***synchronously*** -- therefore, components cannot receive `async` functions as values. To
+ * workaround this, the function passed to `fetchToken` for the Plaid Link JSX components must use promise chaining
+ * instead of relying on `async/await` syntax.
+ *
+ * ## Usage
+ *
+ * ```tsx
+ * import { PlaidLink } from "@danchez/solid-plaid-link";
+ *
+ * const MyComponent = () => (
+ * fetch("https://api.example.com/plaid/link-token").then((response) => response.json())}
+ * onLoad={() => { ... }}
+ * onLoadError={(error) => { ... }}
+ * onSuccess={(publicToken, metaData) => { ... }}
+ * onEvent={(eventName, metaData) => { ... }}
+ * onExit={(error, metaData) => { ... }}
+ * >
+ * Open Plaid Link
+ *
+ * );
+ */
+const PlaidLink = (props: ParentProps) => {
+ const [plaidProps, btnProps] = splitProps(props, PLAID_LINK_PROPS);
+ const [localProps, otherBtnProps] = splitProps(btnProps, ["disabled", "onClick"]);
+ const { ready, error, plaidLink } = createPlaidLink(() => plaidProps);
+
+ createEffect(() => {
+ if (!ready()) return;
+ props.onReady?.();
+ });
+
+ createEffect(() => {
+ if (!error()) return;
+ props.onLoadError?.(error() as Error);
+ });
+
+ return (
+ {
+ if (typeof localProps.onClick === "function") {
+ localProps.onClick?.(e);
+ }
+
+ plaidLink().open();
+ }}
+ />
+ );
+};
+
+export default PlaidLink;
diff --git a/src/components/__tests__/PlaidEmbeddedLink.test.tsx b/src/components/__tests__/PlaidEmbeddedLink.test.tsx
new file mode 100644
index 0000000..6ca22d6
--- /dev/null
+++ b/src/components/__tests__/PlaidEmbeddedLink.test.tsx
@@ -0,0 +1,184 @@
+import { render, waitFor } from "@solidjs/testing-library";
+import { createScript } from "solid-create-script";
+import { createSignal } from "solid-js";
+import { beforeEach, describe, expect, it, type MockedFunction, vi } from "vitest";
+
+import { createFakeResource, sleep } from "../../testUtils";
+import type { Plaid } from "../../types";
+import PlaidEmbeddedLink from "../PlaidEmbeddedLink";
+
+vi.mock("solid-create-script");
+const mockCreateScript = createScript as MockedFunction;
+
+describe("COMPONENT: ", () => {
+ let createEmbeddedSpy: MockedFunction;
+ let destroySpy: MockedFunction<() => void>;
+
+ beforeEach(() => {
+ destroySpy = vi.fn();
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ createEmbeddedSpy = vi.fn((_config, _target) => ({
+ destroy: destroySpy,
+ }));
+
+ window.Plaid = {
+ createEmbedded: createEmbeddedSpy,
+ create: vi.fn(),
+ };
+ });
+
+ it("does not recreate embedded instance if Plaid config did not change", () => {
+ mockCreateScript.mockImplementationOnce(() =>
+ createFakeResource({
+ loading: false,
+ state: "ready",
+ }),
+ );
+
+ const [style, setStyle] = createSignal({ backgroundColor: "red" });
+
+ render(() => (
+ Promise.resolve({ link_token: "test-token", expiration: "2024-11-25T14:30:00Z" })}
+ onSuccess={vi.fn()}
+ style={style()}
+ />
+ ));
+
+ waitFor(() => expect(createEmbeddedSpy).toHaveBeenCalledTimes(1));
+
+ setStyle({ backgroundColor: "blue" });
+
+ waitFor(() => expect(createEmbeddedSpy).toHaveBeenCalledTimes(1));
+ });
+
+ it("recreates embedded Plaid instance when config changes", () => {
+ mockCreateScript.mockImplementationOnce(() =>
+ createFakeResource({
+ loading: false,
+ state: "ready",
+ }),
+ );
+
+ const [onSuccess, setOnSuccess] = createSignal(() => {});
+
+ render(() => (
+
+ Promise.resolve({
+ link_token: "test-token",
+ expiration: "2024-11-25T14:30:00Z",
+ })
+ }
+ onSuccess={onSuccess}
+ />
+ ));
+
+ waitFor(() => expect(createEmbeddedSpy).toHaveBeenCalledTimes(1));
+
+ setOnSuccess(vi.fn());
+
+ waitFor(() => expect(createEmbeddedSpy).toHaveBeenCalledTimes(2));
+ });
+
+ it("does not create embedded Plaid instance when script is still loading", async () => {
+ const consoleSpy = vi.spyOn(console, "error").mockImplementationOnce(() => {});
+
+ mockCreateScript.mockImplementationOnce(() =>
+ createFakeResource({
+ loading: true,
+ state: "pending",
+ }),
+ );
+
+ render(() => (
+ Promise.resolve({ link_token: "test-token", expiration: "2024-11-25T14:30:00Z" })}
+ onSuccess={vi.fn()}
+ />
+ ));
+
+ await sleep(1);
+
+ expect(consoleSpy).not.toHaveBeenCalled();
+ expect(createEmbeddedSpy).toHaveBeenCalledTimes(0);
+ });
+
+ it("does not create embedded Plaid instance when script errors", async () => {
+ const consoleSpy = vi.spyOn(console, "error").mockImplementationOnce(() => {});
+
+ mockCreateScript.mockImplementationOnce(() =>
+ createFakeResource({
+ loading: false,
+ state: "errored",
+ error: new Error("SCRIPT_LOAD_ERROR"),
+ }),
+ );
+
+ render(() => (
+ Promise.resolve({ link_token: "test-token", expiration: "2024-11-25T14:30:00Z" })}
+ onSuccess={vi.fn()}
+ />
+ ));
+
+ await waitFor(() =>
+ expect(consoleSpy).toHaveBeenCalledWith("solid-plaid-link: Error loading Plaid script.", "SCRIPT_LOAD_ERROR"),
+ );
+
+ expect(createEmbeddedSpy).toHaveBeenCalledTimes(0);
+ });
+
+ it("does not create embedded Plaid instance when token missing", async () => {
+ const consoleSpy = vi.spyOn(console, "error").mockImplementationOnce(() => {});
+
+ mockCreateScript.mockImplementationOnce(() =>
+ createFakeResource({
+ loading: false,
+ state: "ready",
+ }),
+ );
+
+ render(() => (
+ Promise.resolve({ link_token: "", expiration: "2024-11-25T14:30:00Z" })}
+ onSuccess={vi.fn()}
+ />
+ ));
+
+ await waitFor(() =>
+ expect(consoleSpy).toHaveBeenCalledWith(
+ "solid-plaid-link: fetchToken response missing link token or expiration date.",
+ ),
+ );
+
+ expect(createEmbeddedSpy).toHaveBeenCalledTimes(0);
+ });
+
+ it("does not create embedded Plaid instance when expiration missing", async () => {
+ const consoleSpy = vi.spyOn(console, "error").mockImplementationOnce(() => {});
+
+ mockCreateScript.mockImplementationOnce(() =>
+ createFakeResource({
+ loading: false,
+ state: "ready",
+ }),
+ );
+
+ render(() => (
+ Promise.resolve({ link_token: "test-token", expiration: "" })}
+ onSuccess={vi.fn()}
+ />
+ ));
+
+ await waitFor(() =>
+ expect(consoleSpy).toHaveBeenCalledWith(
+ "solid-plaid-link: fetchToken response missing link token or expiration date.",
+ ),
+ );
+
+ expect(createEmbeddedSpy).toHaveBeenCalledTimes(0);
+ });
+});
diff --git a/src/components/__tests__/PlaidLink.test.tsx b/src/components/__tests__/PlaidLink.test.tsx
new file mode 100644
index 0000000..01fa6ed
--- /dev/null
+++ b/src/components/__tests__/PlaidLink.test.tsx
@@ -0,0 +1,80 @@
+import { render } from "@solidjs/testing-library";
+import { userEvent } from "@testing-library/user-event";
+import { createScript } from "solid-create-script";
+import { createRoot, createSignal } from "solid-js";
+import { beforeEach, describe, expect, it, type MockedFunction, vi } from "vitest";
+
+import { createFakeResource } from "../../testUtils";
+import type { PlaidHandler } from "../../types";
+import PlaidLink from "../PlaidLink";
+
+vi.mock("solid-create-script");
+const mockCreateScript = createScript as MockedFunction;
+
+describe("COMPONENT: ", () => {
+ let fakePlaidLink: PlaidHandler;
+
+ beforeEach(() => {
+ mockCreateScript.mockImplementationOnce(() =>
+ createFakeResource({
+ loading: false,
+ state: "ready",
+ }),
+ );
+
+ fakePlaidLink = {
+ submit: vi.fn(),
+ exit: vi.fn(),
+ open: vi.fn(),
+ destroy: vi.fn(),
+ };
+
+ window.Plaid = {
+ createEmbedded: vi.fn(),
+ create: () => fakePlaidLink,
+ };
+ });
+
+ it("renders correctly on screen", async () => {
+ const { getByText } = render(() => (
+ ({ link_token: "test-token", expiration: "2024-11-25T14:30:00Z" }))}
+ onSuccess={vi.fn()}
+ >
+ Open
+
+ ));
+
+ expect(getByText("Open")).toBeInTheDocument();
+
+ await userEvent.click(getByText("Open"));
+
+ expect(fakePlaidLink.open).toHaveBeenCalled();
+ });
+
+ it("runs additional onClick logic when supplied as a prop", async () => {
+ const [didClick, setDidClick] = createSignal(false);
+
+ const { getByText } = render(() => (
+ ({ link_token: "test-token", expiration: "2024-11-25T14:30:00Z" }))}
+ onSuccess={vi.fn()}
+ onClick={() => setDidClick(true)}
+ >
+ Open
+
+ ));
+
+ await createRoot(async (dispose) => {
+ expect(getByText("Open")).toBeInTheDocument();
+ expect(didClick()).toBe(false);
+
+ await userEvent.click(getByText("Open"));
+
+ expect(fakePlaidLink.open).toHaveBeenCalled();
+ expect(didClick()).toBe(true);
+
+ dispose();
+ });
+ });
+});
diff --git a/src/constants.ts b/src/constants.ts
new file mode 100644
index 0000000..3045e6c
--- /dev/null
+++ b/src/constants.ts
@@ -0,0 +1,10 @@
+export const PLAID_LINK_STABLE_URL = "https://cdn.plaid.com/link/v2/stable/link-initialize.js";
+
+export const PLAID_LINK_PROPS = [
+ "fetchToken",
+ "onEvent",
+ "onExit",
+ "onLoad",
+ "onSuccess",
+ "receivedRedirectUri",
+] as const;
diff --git a/src/hooks/__tests__/createPlaidLink.test.tsx b/src/hooks/__tests__/createPlaidLink.test.tsx
new file mode 100644
index 0000000..1b3cb5a
--- /dev/null
+++ b/src/hooks/__tests__/createPlaidLink.test.tsx
@@ -0,0 +1,258 @@
+import { waitFor } from "@solidjs/testing-library";
+import { createScript } from "solid-create-script";
+import { createRoot } from "solid-js";
+import { afterEach, beforeEach, describe, expect, it, type MockedFunction, vi } from "vitest";
+
+import { createFakeResource } from "../../testUtils";
+import type { PlaidHandler } from "../../types";
+import createPlaidLink from "../createPlaidLink";
+
+vi.mock("solid-create-script");
+const mockCreateScript = createScript as MockedFunction;
+
+const TEST_TOKEN = "test-token";
+
+describe("HOOK: createPlaidLink", () => {
+ let mockPlaidHandler: PlaidHandler;
+
+ beforeEach(() => {
+ mockPlaidHandler = {
+ open: vi.fn(),
+ submit: vi.fn(),
+ exit: vi.fn(),
+ destroy: vi.fn(),
+ };
+
+ window.Plaid = {
+ createEmbedded: vi.fn(),
+ create: ({ onLoad }) => {
+ onLoad?.();
+ return mockPlaidHandler;
+ },
+ };
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("provides no-op Plaid Link client when not ready", () => {
+ const consoleSpy = vi.spyOn(console, "warn").mockImplementationOnce(() => {});
+
+ mockCreateScript.mockImplementationOnce(() =>
+ createFakeResource({
+ loading: true,
+ state: "pending",
+ }),
+ );
+
+ createRoot((dispose) => {
+ const { plaidLink } = createPlaidLink(() => ({
+ fetchToken: () => Promise.resolve({ link_token: TEST_TOKEN, expiration: "2024-11-25T14:30:00Z" }),
+ onSuccess: vi.fn(),
+ }));
+
+ plaidLink().open();
+ expect(mockPlaidHandler.open).not.toHaveBeenCalled();
+
+ waitFor(() =>
+ expect(consoleSpy).toHaveBeenCalledWith("solid-plaid-link: Plaid Link is not ready yet. This is a no-op."),
+ );
+
+ plaidLink().exit();
+ expect(mockPlaidHandler.exit).not.toHaveBeenCalled();
+
+ plaidLink().submit({ phone_number: "" });
+ expect(mockPlaidHandler.submit).not.toHaveBeenCalled();
+
+ dispose();
+ });
+ });
+
+ it("is not ready when script is loading", () => {
+ mockCreateScript.mockImplementationOnce(() =>
+ createFakeResource({
+ loading: true,
+ state: "pending",
+ }),
+ );
+
+ createRoot((dispose) => {
+ const { ready, error } = createPlaidLink(() => ({
+ fetchToken: () => Promise.resolve({ link_token: TEST_TOKEN, expiration: "2024-11-25T14:30:00Z" }),
+ onSuccess: vi.fn(),
+ }));
+
+ expect(ready()).toEqual(false);
+ expect(error()).toEqual(null);
+
+ dispose();
+ });
+ });
+
+ it("is not ready when script fails to load", () => {
+ const consoleSpy = vi.spyOn(console, "error").mockImplementationOnce(() => {});
+
+ mockCreateScript.mockImplementationOnce(() =>
+ createFakeResource({
+ loading: false,
+ state: "errored",
+ error: new Error("SCRIPT_LOAD_ERROR"),
+ }),
+ );
+
+ createRoot(async (dispose) => {
+ const { ready, error } = createPlaidLink(() => ({
+ fetchToken: () => Promise.resolve({ link_token: TEST_TOKEN, expiration: "2024-11-25T14:30:00Z" }),
+ onSuccess: vi.fn(),
+ }));
+
+ expect(ready()).toEqual(false);
+ expect(error()).toEqual(new Error("SCRIPT_LOAD_ERROR"));
+
+ await waitFor(() =>
+ expect(consoleSpy).toHaveBeenCalledWith("solid-plaid-link: Error loading Plaid script.", "SCRIPT_LOAD_ERROR"),
+ );
+
+ dispose();
+ });
+ });
+
+ it("is not ready when fetchToken request is loading", () => {
+ mockCreateScript.mockImplementationOnce(() =>
+ createFakeResource({
+ loading: false,
+ state: "ready",
+ }),
+ );
+
+ createRoot((dispose) => {
+ const { ready } = createPlaidLink(() => ({
+ fetchToken: () => Promise.resolve({ link_token: TEST_TOKEN, expiration: "" }),
+ onSuccess: vi.fn(),
+ }));
+
+ expect(ready()).toEqual(false);
+
+ dispose();
+ });
+ });
+
+ it("is not ready when fetchToken request fails", () => {
+ const consoleSpy = vi.spyOn(console, "error").mockImplementationOnce(() => {});
+
+ mockCreateScript.mockImplementationOnce(() =>
+ createFakeResource({
+ loading: false,
+ state: "ready",
+ }),
+ );
+
+ createRoot(async (dispose) => {
+ const { ready, error } = createPlaidLink(() => ({
+ fetchToken: () => Promise.reject(new Error("FETCH_TOKEN_ERROR")),
+ onSuccess: vi.fn(),
+ }));
+
+ expect(ready()).toEqual(false);
+ waitFor(() => expect(error()).toEqual(new Error("FETCH_TOKEN_ERROR")));
+
+ await waitFor(() =>
+ expect(consoleSpy).toHaveBeenCalledWith("solid-plaid-link: Error fetching link token.", "FETCH_TOKEN_ERROR"),
+ );
+
+ dispose();
+ });
+ });
+
+ it("is not ready when fetchToken request fails to return a link token", () => {
+ const consoleSpy = vi.spyOn(console, "error").mockImplementationOnce(() => {});
+
+ mockCreateScript.mockImplementationOnce(() =>
+ createFakeResource({
+ loading: false,
+ state: "ready",
+ }),
+ );
+
+ createRoot(async (dispose) => {
+ const { ready, error } = createPlaidLink(() => ({
+ fetchToken: () => Promise.resolve({ link_token: "", expiration: "2024-11-25T14:30:00Z" }),
+ onSuccess: vi.fn(),
+ }));
+
+ expect(ready()).toEqual(false);
+ waitFor(() => expect(error()).toEqual(new Error("FETCH_TOKEN_ERROR")));
+
+ await waitFor(() =>
+ expect(consoleSpy).toHaveBeenCalledWith("solid-plaid-link: Error fetching link token.", "FETCH_TOKEN_ERROR"),
+ );
+
+ dispose();
+ });
+ });
+
+ it("is not ready when fetchToken request fails to return a link token expiration", () => {
+ const consoleSpy = vi.spyOn(console, "error").mockImplementationOnce(() => {});
+
+ mockCreateScript.mockImplementationOnce(() =>
+ createFakeResource({
+ loading: false,
+ state: "ready",
+ }),
+ );
+
+ createRoot(async (dispose) => {
+ const { ready, error } = createPlaidLink(() => ({
+ fetchToken: () => Promise.resolve({ link_token: TEST_TOKEN, expiration: "" }),
+ onSuccess: vi.fn(),
+ }));
+
+ expect(ready()).toEqual(false);
+ waitFor(() => expect(error()).toEqual(new Error("FETCH_TOKEN_ERROR")));
+
+ await waitFor(() =>
+ expect(consoleSpy).toHaveBeenCalledWith("solid-plaid-link: Error fetching link token.", "FETCH_TOKEN_ERROR"),
+ );
+
+ dispose();
+ });
+ });
+
+ it("creates Plaid Link handler when script is ready and link token received", async () => {
+ vi.setSystemTime(new Date(2024, 10, 24).getTime());
+
+ const setTimeoutMock = vi.spyOn(global, "setTimeout").mockImplementation(() => {
+ return 1234 as unknown as Timer;
+ });
+
+ mockCreateScript.mockImplementationOnce(() =>
+ createFakeResource({
+ loading: false,
+ state: "ready",
+ }),
+ );
+
+ await createRoot(async (dispose) => {
+ const { ready, plaidLink } = createPlaidLink(() => ({
+ fetchToken: () => Promise.resolve({ link_token: TEST_TOKEN, expiration: "2024-11-24T08:00:00Z" }),
+ onSuccess: vi.fn(),
+ }));
+
+ await waitFor(() => expect(ready()).toEqual(true));
+
+ plaidLink().open();
+ expect(mockPlaidHandler.open).toHaveBeenCalled();
+
+ plaidLink().exit();
+ expect(mockPlaidHandler.exit).toHaveBeenCalled();
+
+ plaidLink().submit({ phone_number: "" });
+ expect(mockPlaidHandler.submit).toHaveBeenCalled();
+
+ expect(setTimeoutMock).toHaveBeenCalledWith(expect.any(Function), expect.any(Number));
+
+ dispose();
+ });
+ });
+});
diff --git a/src/hooks/createPlaidLink.tsx b/src/hooks/createPlaidLink.tsx
new file mode 100644
index 0000000..11ec1f0
--- /dev/null
+++ b/src/hooks/createPlaidLink.tsx
@@ -0,0 +1,156 @@
+import { createScript } from "solid-create-script";
+import { type Accessor, createEffect, createResource, createSignal, onCleanup } from "solid-js";
+
+import { PLAID_LINK_STABLE_URL } from "../constants";
+import type { CreatePlaidLinkConfig, PlaidLinkHandler } from "../types";
+
+const NOOP_PLAID_HANDLER: PlaidLinkHandler = {
+ open: () => {
+ console.warn("solid-plaid-link: Plaid Link is not ready yet. This is a no-op.");
+ },
+ exit: () => {},
+ submit: () => {},
+};
+
+/**
+ * ## Summary
+ *
+ * Utility hook that dynamically loads the Plaid script and manages the Plaid Link creation for you. Use this hook when
+ * you want full control over how and when to display the Plaid Link UI. Regardless of how this hook is used, it takes
+ * care of refreshing the link token before it expires and destroying the Plaid Link UI when unmounting on your behalf.
+ *
+ * - _Note: A new Plaid Link instance is created any time the configuration props change._
+ *
+ * In order to initialize Plaid Link via this hook, a [linkToken](https://plaid.com/docs/api/link/#linktokencreate) is
+ * required from Plaid. You can fetch the link token from your server via the required `fetchToken` field. Once Link has
+ * been initialized, it returns a temporary `publicToken`. This `publicToken` must then be exchanged for a permanent
+ * `accessToken` which is used to make product requests.
+ *
+ * - _Note: the `publicToken` to `accessToken` exchange should be handled by your API server that fulfills requests for
+ * your SPA._
+ *
+ * ## Usage
+ * ```tsx
+ * import { Switch, Match, onMount } from "solid-js";
+ * import { createPlaidLink } from "@danchez/solid-plaid-link";
+ *
+ * const ExampleOne = () => {
+ * const { ready, error, plaidLink } = createPlaidLink(() => ({
+ * fetchToken: async () => {
+ * const response = await fetch("https://api.example.com/plaid/link-token");
+ * const { link_token, expiration } = await response.json();
+ * return { link_token, expiration };
+ * },
+ * onLoad: () => { ... },
+ * onSuccess: (publicToken, metaData) => { ... },
+ * onEvent: (eventName, metaData) => { ... },
+ * onExit: (error, metaData) => { ... },
+ * }));
+ *
+ * return (
+ *
+ * {error().message}
+ *
+ * { plaidLink().open(); }}>
+ * Open Plaid Link
+ *
+ *
+ *
+ * );
+ * };
+ *
+ * const ExampleTwo = () => {
+ * const { ready, plaidLink } = createPlaidLink(() => ({
+ * fetchToken: async () => {
+ * const response = await fetch("https://api.example.com/plaid/link-token");
+ * const { link_token, expiration } = await response.json();
+ * return { link_token, expiration };
+ * },
+ * onLoad: () => { ... },
+ * onSuccess: (publicToken, metaData) => { ... },
+ * onEvent: (eventName, metaData) => { ... },
+ * onExit: (error, metaData) => { ... },
+ * }));
+ *
+ * createEffect(() => {
+ * if (!ready()) return;
+ *
+ * plaidLink().open();
+ * });
+ *
+ * return (...);
+ * };
+ */
+const createPlaidLink = (config: Accessor) => {
+ const script = createScript(PLAID_LINK_STABLE_URL, {
+ defer: true,
+ });
+
+ const [tokenRequest, { refetch }] = createResource(config().fetchToken);
+
+ const [plaidLink, setPlaidLink] = createSignal(NOOP_PLAID_HANDLER);
+ const [ready, setReady] = createSignal(false);
+
+ const error: Accessor = () => script.error || tokenRequest.error || null;
+
+ createEffect(() => {
+ if (script.loading || tokenRequest.loading) return;
+
+ if (script.error || typeof window === "undefined" || !window.Plaid) {
+ console.error("solid-plaid-link: Error loading Plaid script.", script.error?.message);
+ return;
+ }
+
+ if (tokenRequest.error) {
+ console.error("solid-plaid-link: Error fetching link token.", tokenRequest.error.message);
+ return;
+ }
+
+ const link_token = tokenRequest()?.link_token || "";
+ const expiration = tokenRequest()?.expiration || "";
+
+ if (!link_token || !expiration) {
+ console.error("solid-plaid-link: fetchToken response missing link token or expiration date.");
+ return;
+ }
+
+ const plaidHandler = window.Plaid.create({
+ ...config(),
+ token: link_token,
+ onLoad: () => {
+ setReady(true);
+ config().onLoad?.();
+ },
+ });
+
+ setPlaidLink(plaidHandler);
+
+ const now = Date.now();
+ const expirationTime = new Date(expiration).getTime();
+ const timeUntilLinkTokenExpires = expirationTime - now;
+
+ // Link Tokens expire after 4 hours. Set a timer to refresh the token 5 minutes before it expires.
+ const timerId = setTimeout(refetch, timeUntilLinkTokenExpires - 5 * 60 * 1000);
+
+ onCleanup(() => {
+ plaidHandler.exit({ force: true });
+ plaidHandler.destroy();
+
+ setPlaidLink(NOOP_PLAID_HANDLER);
+ setReady(false);
+
+ clearTimeout(timerId);
+ });
+ });
+
+ return {
+ /** Flag whether Plaid Link has successfully loaded or not */
+ ready,
+ /** Possible error from either downloading Plaid script from CDN or error fetching link token */
+ error,
+ /** The Plaid Link client */
+ plaidLink,
+ } as const;
+};
+
+export default createPlaidLink;
diff --git a/src/index.tsx b/src/index.tsx
index 148fe81..673585e 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,2 +1,3 @@
-// Main library export site
-// Use playground app (via Vite) to test and document the library
+export { default as PlaidEmbeddedLink } from "./components/PlaidEmbeddedLink";
+export { default as PlaidLink } from "./components/PlaidLink";
+export { default as createPlaidLink } from "./hooks/createPlaidLink";
diff --git a/src/testUtils.ts b/src/testUtils.ts
new file mode 100644
index 0000000..4dd0426
--- /dev/null
+++ b/src/testUtils.ts
@@ -0,0 +1,25 @@
+import type { Resource } from "solid-js";
+
+type ResourceConfig = {
+ readonly state: "ready" | "unresolved" | "pending" | "errored" | "refreshing";
+ readonly loading: boolean;
+ readonly error?: Error | null;
+};
+
+export const createFakeResource = (config: ResourceConfig) => {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ const mockResource: Mocked> = vi.fn(() => ({
+ id: 1,
+ type: "mock-event",
+ timestamp: new Date(),
+ }));
+
+ mockResource.state = config.state;
+ mockResource.loading = config.loading;
+ mockResource.error = config.error || null;
+
+ return mockResource;
+};
+
+export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
diff --git a/src/types.ts b/src/types.ts
new file mode 100644
index 0000000..b6725d8
--- /dev/null
+++ b/src/types.ts
@@ -0,0 +1,227 @@
+export type PlaidAccountType = "depository" | "credit" | "loan" | "investment" | "other";
+
+export type PlaidAccountVerificationStatus =
+ | "pending_automatic_verification"
+ | "pending_manual_verification"
+ | "manually_verified"
+ | "verification_expired"
+ | "verification_failed"
+ | "database_matched"
+ | "database_insights_pending";
+
+export type PlaidAccountTransferStatus = "COMPLETE" | "INCOMPLETE";
+
+export type PlaidAccount = {
+ /** The Plaid account_id */
+ readonly id: string;
+ /** The official account name */
+ readonly name: string;
+ /** The last 2-4 alphanumeric characters of an account's official account number. Note that the mask may be non-unique
+ * between an Item's accounts. It may also not match the mask that the bank displays to the user. */
+ readonly mask: string | null;
+ readonly type: PlaidAccountType;
+ readonly subtype: string;
+ /** Indicates an Item's micro-deposit-based verification or database verification status. */
+ readonly verification_status: PlaidAccountVerificationStatus | null;
+ /** If micro-deposit verification is being used, indicates whether the account being verified is a business or personal
+ * account. */
+ readonly class_type: string | null;
+};
+
+export type PlaidInstitution = {
+ readonly name: string;
+ readonly institution_id: string;
+};
+
+/** See https://plaid.com/docs/link/web/#link-web-onexit-metadata for more details */
+export type PlaidExitStatus =
+ | "requires_questions"
+ | "requires_selections"
+ | "requires_code"
+ | "choose_device"
+ | "requires_credentials"
+ | "requires_account_selection"
+ | "requires_oauth"
+ | "institution_not_found"
+ | "institution_not_supported";
+
+export type PlaidLinkError = {
+ /** A broad categorization of the error. */
+ readonly error_type: string;
+ /** The particular error code. Each error_type has a specific set of error_codes. */
+ readonly error_code: string;
+ /** A developer-friendly representation of the error code. */
+ readonly error_message: string;
+ /** A user-friendly representation of the error code. null if the error is not related to user action. This may
+ * change over time and is not safe for programmatic use. */
+ readonly display_message: string | null;
+};
+
+export type PlaidLinkOnSuccessMetadata = {
+ /** An institution object. If the Item was created via Same-Day micro-deposit verification, will be null. */
+ readonly institution: PlaidInstitution | null;
+ /** A list of accounts attached to the connected Item. If Account Select is enabled via the developer dashboard,
+ * accounts will only include selected accounts */
+ readonly accounts: PlaidAccount[];
+ /** A unique identifier associated with a user's actions and events through the Link flow. Include this identifier when
+ * opening a support ticket for faster turnaround. */
+ readonly link_session_id: string;
+ /** The status of a transfer. Returned only when Transfer UI is implemented. */
+ readonly transfer_status: PlaidAccountTransferStatus | null;
+};
+
+export type PlaidLinkOnExitMetadata = {
+ readonly institution: PlaidInstitution | null;
+ // see possible values for status at https://plaid.com/docs/link/web/#link-web-onexit-status
+ readonly status: PlaidExitStatus;
+ /** A unique identifier associated with a user's actions and events through the Link flow. Include this identifier
+ * when opening a support ticket for faster turnaround. */
+ readonly link_session_id: string;
+ /** The request ID for the last request made by Link. This can be shared with Plaid Support to expedite investigation. */
+ readonly request_id: string;
+};
+
+export interface PlaidLinkOnEventMetadata {
+ /** The account number mask extracted from the user-provided account number. If the user-inputted account number is four
+ * digits long, account_number_mask is empty. Emitted by `SUBMIT_ACCOUNT_NUMBER`. */
+ readonly account_numbe_mask: string | null;
+ /** The error type that the user encountered. Emitted by: `ERROR`, `EXIT`. */
+ readonly error_type: string | null;
+ /** The error code that the user encountered. Emitted by `ERROR`, `EXIT`. */
+ readonly error_code: string | null;
+ /** The error message that the user encountered. Emitted by: `ERROR`, `EXIT`. */
+ readonly error_message: string | null;
+ /** The status key indicates the point at which the user exited the Link flow. Emitted by: `EXIT` */
+ readonly exit_status: string | null;
+ /** The ID of the selected institution. Emitted by: all events. */
+ readonly institution_id: string | null;
+ /** The name of the selected institution. Emitted by: all events. */
+ readonly institution_name: string | null;
+ /** The query used to search for institutions. Emitted by: `SEARCH_INSTITUTION`. */
+ readonly institution_search_query: string | null;
+ /** Indicates if the current Link session is an update mode session. Emitted by: `OPEN`. */
+ readonly is_update_mode: string | null;
+ /** The reason this institution was matched. This will be either returning_user or routing_number if emitted by:
+ * `MATCHED_SELECT_INSTITUTION`. Otherwise, this will be `SAVED_INSTITUTION` or `AUTO_SELECT_SAVED_INSTITUTION` if
+ * emitted by: `SELECT_INSTITUTION`. */
+ readonly match_reason: string | null;
+ /** The routing number submitted by user at the micro-deposits routing number pane. Emitted by `SUBMIT_ROUTING_NUMBER`. */
+ readonly routing_number: string | null;
+ /** If set, the user has encountered one of the following `MFA` types: code, device, questions, selections. Emitted by:
+ * `SUBMIT_MFA` and `TRANSITION_VIEW` when view_name is `MFA` */
+ readonly mfa_type: string | null;
+ /** The name of the view that is being transitioned to. Emitted by: `TRANSITION_VIEW`. See possible values at
+ * https://plaid.com/docs/link/web/#link-web-onevent-metadata-view-name */
+ readonly view_name: string | null;
+ /** The request ID for the last request made by Link. This can be shared with Plaid Support to expedite investigation.
+ * Emitted by: all events. */
+ readonly request_id: string;
+ /** The link_session_id is a unique identifier for a single session of Link. It's always available and will stay constant
+ * throughout the flow. Emitted by: all events. */
+ readonly link_session_id: string;
+ /** An ISO 8601 representation of when the event occurred. For example `2017-09-14T14:42:19.350Z`. Emitted by: all events. */
+ readonly timestamp: string;
+ /** Either the verification method for a matched institution selected by the user or the Auth Type Select flow type
+ * selected by the user. If selection is used to describe selected verification method, then possible values are
+ * `phoneotp` or `password`; if selection is used to describe the selected Auth Type Select flow, then possible values
+ * are `flow_type_manual` or `flow_type_instant`. Emitted by: `MATCHED_SELECT_VERIFY_METHOD` and `SELECT_AUTH_TYPE`. */
+ readonly selection: string | null;
+}
+
+// The following event names are stable and will not be deprecated or changed
+export type PlaidLinkStableEvent =
+ | "OPEN"
+ | "EXIT"
+ | "HANDOFF"
+ | "SELECT_INSTITUTION"
+ | "ERROR"
+ | "BANK_INCOME_INSIGHTS_COMPLETED"
+ | "IDENTITY_VERIFICATION_PASS_SESSION"
+ | "IDENTITY_VERIFICATION_FAIL_SESSION";
+
+export type PlaidCreateLinkToken = {
+ /** Must be supplied to Link in order to initialize it and receive a public_token, which can be exchanged for an access_token. */
+ readonly link_token: string;
+ /** The expiration date for the link_token in ISO 8601 format */
+ readonly expiration: string;
+ /** A unique identifier for the request, which can be used for troubleshooting. This identifier, like all Plaid identifiers, is case sensitive. */
+ readonly request_id?: string;
+};
+
+export type PlaidLinkOnEvent = (
+ // see possible values for eventName at
+ // https://plaid.com/docs/link/web/#link-web-onevent-eventName.
+ // Events other than stable events are informational and subject to change,
+ // and therefore should not be used to customize your product experience.
+ eventName: PlaidLinkStableEvent | string,
+ metadata: PlaidLinkOnEventMetadata,
+) => void;
+
+export type PlaidCallbacks = {
+ /** A function that is called when a user successfully links an Item. */
+ readonly onSuccess: (publicToken: string, metadata: PlaidLinkOnSuccessMetadata) => void;
+ /** A function that is called when a user exits Link without successfully linking an Item, or when an error occurs
+ * during Link initialization. */
+ readonly onExit?: (error: PlaidLinkError | null, metadata: PlaidLinkOnExitMetadata) => void;
+ /** A function that is called when a user reaches certain points in the Link flow. See possible values for `eventName`
+ * at https://plaid.com/docs/link/web/#link-web-onevent-eventName. Events other than stable events are informational and
+ * subject to change and therefore should not be used to customize your product experience. */
+ readonly onEvent?: (eventName: PlaidLinkStableEvent | string, metadata: PlaidLinkOnEventMetadata) => void;
+ /** A function that is called when the Link module has finished loading. Calls to plaidLinkHandler.open() prior to the
+ * `onLoad` callback will be delayed until the module is fully loaded. */
+ readonly onLoad?: () => void;
+};
+
+export type CreatePlaidLinkConfig = PlaidCallbacks & {
+ /** Fetcher to retrieve the `link_token` required to initialize Plaid Link. The server supporting your app should
+ * create a link_token using the Plaid `/link/token/create` endpoint. See https://plaid.com/docs/api/link/#linktokencreate
+ * for more details. */
+ readonly fetchToken: () => Promise;
+ /** required on the second-initialization of link when using Link with a `redirect_uri` to support OAuth flows. */
+ readonly receivedRedirectUri?: string;
+};
+
+export type PlaidHandlerSubmissionData = {
+ readonly phone_number: string | null;
+};
+
+type ExitOptions = {
+ /** If `true`, Link will exit immediately. Otherwise an exit confirmation screen may be presented to the user. */
+ readonly force?: boolean;
+};
+
+export type PlaidHandler = {
+ /** Display the Consent Pane view to your user, starting the Link flow.
+ * Once open is called, you will begin receiving events via the `onEvent` callback. */
+ readonly open: () => void;
+ readonly submit: (data: PlaidHandlerSubmissionData) => void;
+ /** Programmatically close Link. Calling this will trigger either the `onExit` or `onSuccess` callbacks. */
+ readonly exit: (opts?: ExitOptions) => void;
+ /** Destroy the Link handler instance, properly removing any DOM artifacts that were created by it. */
+ readonly destroy: () => void;
+};
+
+export type PlaidLinkHandler = Omit;
+
+export type PlaidEmbeddedHandler = {
+ readonly destroy: () => void;
+};
+
+type PlaidCreateConfig = PlaidCallbacks & {
+ // Provide a link_token associated with your account. Create one
+ // using the /link/token/create endpoint.
+ readonly token?: string | null;
+ /** required on the second-initialization of link when using Link with a `redirect_uri` to support OAuth flows. */
+ readonly receivedRedirectUri?: string;
+};
+
+export type Plaid = {
+ create: (config: PlaidCreateConfig) => PlaidHandler;
+ createEmbedded: (config: PlaidCreateConfig, domTarget: HTMLElement) => PlaidEmbeddedHandler;
+};
+
+declare global {
+ interface Window {
+ Plaid: Plaid;
+ }
+}
diff --git a/tsup.config.ts b/tsup.config.ts
index 55ecfb1..7ad3ca2 100644
--- a/tsup.config.ts
+++ b/tsup.config.ts
@@ -1,34 +1,31 @@
import { defineConfig } from "tsup";
import * as preset from "tsup-preset-solid";
-const generateSolidPresetOptions = (watching: boolean): preset.PresetOptions => ({
+const generateSolidPresetOptions = (): preset.PresetOptions => ({
entries: [
{
// entries with '.tsx' extension will have `solid` export condition generated
entry: "src/index.tsx",
dev_entry: false,
- server_entry: true,
+ server_entry: false,
},
],
- drop_console: !watching, // remove all `console.*` calls and `debugger` statements in prod builds
cjs: false,
});
export default defineConfig((config) => {
const watching = !!config.watch;
- const solidPresetOptions = generateSolidPresetOptions(watching);
+ const solidPresetOptions = generateSolidPresetOptions();
const parsedOptions = preset.parsePresetOptions(solidPresetOptions, watching);
if (!watching) {
const packageFields = preset.generatePackageExports(parsedOptions);
- // console.log(`\npackage.json: \n${JSON.stringify(packageFields, null, 2)}\n\n`);
- /* will update ./package.json with the correct export fields */
preset.writePackageJson(packageFields);
}
const tsupOptions = preset
.generateTsupOptions(parsedOptions)
- .map((tsupOption) => ({ name: "solid-js", ...tsupOption }));
+ .map((tsupOption) => ({ name: "solid-plaid-link", minify: true, ...tsupOption }));
return tsupOptions;
});
diff --git a/vite.config.ts b/vite.config.ts
index 2ad4d95..451157a 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -5,7 +5,7 @@ import { defineConfig } from "vite";
import solidPlugin from "vite-plugin-solid";
import { configDefaults } from "vitest/config";
-const TEST_EXCLUDES = [...configDefaults.exclude, "src/index.tsx"];
+const TEST_EXCLUDES = [...configDefaults.exclude, "src/index.tsx", "src/testUtils.ts", "playground"];
const COVERAGE_EXCLUDE = [...TEST_EXCLUDES, "**/*.test.{ts,tsx}"];
export default defineConfig({
@@ -23,7 +23,6 @@ export default defineConfig({
globals: true,
environment: "jsdom",
setupFiles: ["./setupTests.ts"],
- exclude: TEST_EXCLUDES,
coverage: {
all: true,
provider: "istanbul",