feat(js-sdk): implement custom storage config to support react native (#11467)

* feat(js-sdk): implement custom storage config to support react native

* chore: add changeset

* feat(js-sdk): implement custom storage config to support react native

* chore: add changeset

* test:  add unit tests for custom storage
This commit is contained in:
Ranjith kumar
2025-02-18 13:08:23 +05:30
committed by GitHub
parent ee848bf0f4
commit 32ad13813b
5 changed files with 155 additions and 21 deletions

View File

@@ -0,0 +1,5 @@
---
"@medusajs/js-sdk": minor
---
Implement custom storage configuration option in js-sdk to support react native

View File

@@ -41,7 +41,7 @@
},
"scripts": {
"build": "rimraf dist && tsc -p tsconfig.json && tsc -p tsconfig.esm.json",
"test": "jest --passWithNoTests --runInBand --bail --forceExit",
"test": "jest --passWithNoTests --runInBand --bail --forceExit --detectOpenHandles",
"watch": "tsc --build --watch"
}
}

View File

@@ -4,6 +4,8 @@ import { setupServer } from "msw/node"
import { Client, FetchError, PUBLISHABLE_KEY_HEADER } from "../client"
const baseUrl = "https://someurl.com"
const token = "token-123"
const jwtTokenStorageKey = "medusa_auth_token"
// This is just a network-layer mocking, it doesn't start an actual server
const server = setupServer(
@@ -77,7 +79,7 @@ const server = setupServer(
return HttpResponse.json({ test: "test" })
}),
http.get(`${baseUrl}/jwt`, ({ request }) => {
if (request.headers.get("authorization") === "Bearer token-123") {
if (request.headers.get("authorization") === `Bearer ${token}`) {
return HttpResponse.json({
test: "test",
})
@@ -259,9 +261,8 @@ describe("Client", () => {
})
})
describe("Authrized requests", () => {
describe("Authorized requests", () => {
it("should not store the token by default", async () => {
const token = "token-123" // Eg. from a response after a successful authentication
client.setToken(token)
const resp = await client.fetch<any>("nostore")
@@ -274,13 +275,12 @@ describe("Client", () => {
localStorage: { setItem: jest.fn(), getItem: () => token } as any,
} as any
const token = "token-123" // Eg. from a response after a successful authentication
client.setToken(token)
const resp = await client.fetch<any>("jwt")
expect(resp).toEqual({ test: "test" })
expect(global.window.localStorage.setItem).toHaveBeenCalledWith(
"medusa_auth_token",
jwtTokenStorageKey,
token
)
@@ -289,5 +289,105 @@ describe("Client", () => {
})
})
describe("Custom Storage", () => {
const mockSyncStorage = {
storage: new Map<string, string>(),
getItem: jest.fn(
(key: string) => mockSyncStorage.storage.get(key) || null
),
setItem: jest.fn((key: string, value: string) =>
mockSyncStorage.storage.set(key, value)
),
removeItem: jest.fn((key: string) => mockSyncStorage.storage.delete(key)),
}
const mockAsyncStorage = {
storage: new Map<string, string>(),
getItem: jest.fn(
async (key: string) => mockAsyncStorage.storage.get(key) || null
),
setItem: jest.fn(async (key: string, value: string) =>
mockAsyncStorage.storage.set(key, value)
),
removeItem: jest.fn(async (key: string) =>
mockAsyncStorage.storage.delete(key)
),
}
describe("Synchronous Custom Storage", () => {
let client: Client
beforeEach(() => {
mockSyncStorage.storage.clear()
client = new Client({
baseUrl,
auth: {
type: "jwt",
jwtTokenStorageMethod: "custom",
storage: mockSyncStorage,
},
})
})
it("should store and retrieve token", async () => {
await client.setToken(token)
expect(mockSyncStorage.setItem).toHaveBeenCalledWith(
jwtTokenStorageKey,
token
)
const resp = await client.fetch<any>("jwt")
expect(resp).toEqual({ test: "test" })
expect(mockSyncStorage.getItem).toHaveBeenCalledWith(jwtTokenStorageKey)
})
it("should clear token", async () => {
await client.setToken(token)
await client.clearToken()
const resp = await client.fetch<any>("nostore")
expect(resp).toEqual({ test: "test" })
})
})
describe("Asynchronous Custom Storage", () => {
let client: Client
beforeEach(() => {
mockAsyncStorage.storage.clear()
jest.clearAllMocks()
client = new Client({
baseUrl,
auth: {
type: "jwt",
jwtTokenStorageMethod: "custom",
storage: mockAsyncStorage,
},
})
})
it("should store and retrieve token asynchronously", async () => {
await client.setToken(token)
expect(mockAsyncStorage.setItem).toHaveBeenCalledWith(
jwtTokenStorageKey,
token
)
const resp = await client.fetch<any>("jwt")
expect(resp).toEqual({ test: "test" })
expect(mockAsyncStorage.getItem).toHaveBeenCalled()
})
it("should clear token asynchronously", async () => {
await client.setToken(token)
await client.clearToken()
expect(mockAsyncStorage.removeItem).toHaveBeenCalledWith(
jwtTokenStorageKey
)
const resp = await client.fetch<any>("nostore")
expect(resp).toEqual({ test: "test" })
})
})
})
})

View File

@@ -175,15 +175,15 @@ export class Client {
return { stream: null, abort: abortFunc }
}
setToken(token: string) {
this.setToken_(token)
async setToken(token: string) {
await this.setToken_(token)
}
clearToken() {
this.clearToken_()
async clearToken() {
await this.clearToken_()
}
protected clearToken_() {
protected async clearToken_() {
const { storageMethod, storageKey } = this.getTokenStorageInfo_()
switch (storageMethod) {
case "local": {
@@ -194,6 +194,10 @@ export class Client {
window.sessionStorage.removeItem(storageKey)
break
}
case "custom": {
await this.config.auth?.storage?.removeItem(storageKey)
break
}
case "memory": {
this.token = ""
break
@@ -219,7 +223,7 @@ export class Client {
const headers = new Headers(defaultHeaders)
const customHeaders = {
...this.config.globalHeaders,
...this.getJwtHeader_(),
...(await this.getJwtHeader_()),
...init?.headers,
}
// We use `headers.set` in order to ensure headers are overwritten in a case-insensitive manner.
@@ -278,17 +282,17 @@ export class Client {
: {}
}
protected getJwtHeader_ = (): { Authorization: string } | {} => {
protected async getJwtHeader_(): Promise<{ Authorization: string } | {}> {
// If the user has requested for session storage, we don't want to send the JWT token in the header.
if (this.config.auth?.type === "session") {
return {}
}
const token = this.getToken_()
const token = await this.getToken_()
return token ? { Authorization: `Bearer ${token}` } : {}
}
protected setToken_ = (token: string) => {
protected async setToken_(token: string) {
const { storageMethod, storageKey } = this.getTokenStorageInfo_()
switch (storageMethod) {
case "local": {
@@ -299,6 +303,10 @@ export class Client {
window.sessionStorage.setItem(storageKey, token)
break
}
case "custom": {
await this.config.auth?.storage?.setItem(storageKey, token)
break
}
case "memory": {
this.token = token
break
@@ -306,7 +314,7 @@ export class Client {
}
}
protected getToken_ = () => {
protected async getToken_() {
const { storageMethod, storageKey } = this.getTokenStorageInfo_()
switch (storageMethod) {
case "local": {
@@ -315,17 +323,21 @@ export class Client {
case "session": {
return window.sessionStorage.getItem(storageKey)
}
case "custom": {
return await this.config.auth?.storage?.getItem(storageKey)
}
case "memory": {
return this.token
}
}
return
return null
}
protected getTokenStorageInfo_ = () => {
const hasLocal = hasStorage("localStorage")
const hasSession = hasStorage("sessionStorage")
const hasCustom = Boolean(this.config.auth?.storage)
const storageMethod =
this.config.auth?.jwtTokenStorageMethod ||
@@ -334,10 +346,13 @@ export class Client {
this.config.auth?.jwtTokenStorageKey || this.DEFAULT_JWT_STORAGE_KEY
if (!hasLocal && storageMethod === "local") {
throw new Error("Local JWT storage is only available in the browser")
this.throwError_("Local JWT storage is only available in the browser")
}
if (!hasSession && storageMethod === "session") {
throw new Error("Session JWT storage is only available in the browser")
this.throwError_("Session JWT storage is only available in the browser")
}
if (!hasCustom && storageMethod === "custom") {
this.throwError_("Custom storage was not provided in the config")
}
return {
@@ -345,4 +360,9 @@ export class Client {
storageKey,
}
}
protected throwError_(message: string) {
this.logger.error(message)
throw new Error(message)
}
}

View File

@@ -13,13 +13,22 @@ export type Config = {
auth?: {
type?: "jwt" | "session"
jwtTokenStorageKey?: string
jwtTokenStorageMethod?: "local" | "session" | "memory" | "nostore"
jwtTokenStorageMethod?: "local" | "session" | "memory" | "custom" | "nostore"
fetchCredentials?: "include" | "omit" | "same-origin"
storage?: CustomStorage
}
logger?: Logger
debug?: boolean
}
export type Awaitable<T> = T | Promise<T>
export interface CustomStorage {
getItem(key: string): Awaitable<string | null>
setItem(key: string, value: string): Awaitable<void>
removeItem(key: string): Awaitable<void>
}
export type FetchParams = Parameters<typeof fetch>
export type ClientHeaders = Record<