@flowerforce/flowerbase-client 0.3.0 → 0.3.1-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -39,14 +39,14 @@ describe('flowerbase-client mongo service wrapper', () => {
39
39
 
40
40
  expect((global.fetch as jest.Mock).mock.calls).toHaveLength(8)
41
41
  const [url, request] = (global.fetch as jest.Mock).mock.calls[3]
42
- expect(url).toBe('http://localhost:3000/api/client/v2.0/app/my-app/functions/call')
42
+ expect(url).toBe('http://localhost:3000/api/client/v2.0/app/my-app/functions/call?col=todos-findOne')
43
43
  expect(request.method).toBe('POST')
44
44
  const parsed = JSON.parse(request.body)
45
45
  expect(parsed.service).toBe('mongodb-atlas')
46
46
  expect(parsed.name).toBe('findOne')
47
47
  })
48
48
 
49
- it('supports extended CRUD operations and custom service name', async () => {
49
+ it('supports extended CRUD operations on mongodb-atlas', async () => {
50
50
  global.fetch = jest
51
51
  .fn()
52
52
  .mockResolvedValueOnce({
@@ -65,7 +65,7 @@ describe('flowerbase-client mongo service wrapper', () => {
65
65
  const app = new App({ id: 'my-app', baseUrl: 'http://localhost:3000' })
66
66
  await app.logIn(Credentials.emailPassword('john@doe.com', 'secret123'))
67
67
 
68
- const collection = app.currentUser!.mongoClient('my-service').db('testdb').collection('todos')
68
+ const collection = app.currentUser!.mongoClient('mongodb-atlas').db('testdb').collection('todos')
69
69
 
70
70
  await collection.findOneAndUpdate({ done: false }, { $set: { done: true } })
71
71
  await collection.findOneAndReplace({ done: true }, { done: true, title: 'done' })
@@ -77,7 +77,7 @@ describe('flowerbase-client mongo service wrapper', () => {
77
77
 
78
78
  const calls = (global.fetch as jest.Mock).mock.calls
79
79
  const lastBody = JSON.parse(calls[calls.length - 1][1].body)
80
- expect(lastBody.service).toBe('my-service')
80
+ expect(lastBody.service).toBe('mongodb-atlas')
81
81
  expect(lastBody.name).toBe('deleteMany')
82
82
  })
83
83
  })
package/src/app.ts CHANGED
@@ -1,8 +1,16 @@
1
+ import { Credentials } from './credentials'
1
2
  import { normalizeFunctionResponse } from './functions'
2
3
  import { FlowerbaseHttpError, requestJson, requestStream } from './http'
3
4
  import { SessionManager } from './session'
4
- import { AppConfig, CredentialsLike, FunctionCallPayload, ProfileData, SessionData } from './types'
5
- import { Credentials } from './credentials'
5
+ import {
6
+ AppConfig,
7
+ CredentialsLike,
8
+ FunctionCallPayload,
9
+ MongoDbServiceArguments,
10
+ MongoDbServiceName,
11
+ ProfileData,
12
+ SessionData
13
+ } from './types'
6
14
  import { User } from './user'
7
15
 
8
16
  const API_PREFIX = '/api/client/v2.0'
@@ -43,7 +51,11 @@ export class App {
43
51
  passwordOrArg?: string,
44
52
  ...args: unknown[]
45
53
  ) => Promise<unknown>
46
- resetPassword: (input: { token: string; tokenId: string; password: string }) => Promise<unknown>
54
+ resetPassword: (input: {
55
+ token: string
56
+ tokenId: string
57
+ password: string
58
+ }) => Promise<unknown>
47
59
  }
48
60
 
49
61
  constructor(idOrConfig: string | AppConfig) {
@@ -52,29 +64,8 @@ export class App {
52
64
  this.baseUrl = (config.baseUrl ?? '').replace(/\/$/, '')
53
65
  this.timeout = config.timeout ?? 10000
54
66
  this.sessionManager = new SessionManager(this.id)
55
- const persistedSessionsByUser = this.sessionManager.getSessionsByUser()
56
- for (const [userId, session] of Object.entries(persistedSessionsByUser)) {
57
- this.sessionsByUserId.set(userId, session)
58
- }
59
- this.usersOrder = this.sessionManager.getUsersOrder()
60
- for (const userId of this.sessionsByUserId.keys()) {
61
- if (!this.usersOrder.includes(userId)) {
62
- this.usersOrder.push(userId)
63
- }
64
- }
65
- for (const userId of this.usersOrder) {
66
- this.getOrCreateUser(userId)
67
- }
68
-
69
- const currentSession = this.sessionManager.get()
70
- if (currentSession?.userId) {
71
- this.sessionsByUserId.set(currentSession.userId, currentSession)
72
- this.getOrCreateUser(currentSession.userId)
73
- this.touchUser(currentSession.userId)
74
- this.persistSessionsByUser()
75
- } else {
76
- this.setCurrentSessionFromOrder()
77
- }
67
+ this.restorePersistedUsers()
68
+ this.restoreCurrentSession()
78
69
  this.sessionBootstrapPromise = this.bootstrapSessionOnLoad()
79
70
 
80
71
  this.emailPasswordAuth = {
@@ -144,19 +135,62 @@ export class App {
144
135
  }
145
136
 
146
137
  const users = Object.fromEntries(
147
- [...activeUsers, ...loggedOutUsers].map((userId) => [userId, this.usersById.get(userId)!])
138
+ [...activeUsers, ...loggedOutUsers].map((userId) => [
139
+ userId,
140
+ this.usersById.get(userId)!
141
+ ])
148
142
  )
149
143
  return users
150
144
  }
151
145
 
152
146
  private persistSessionsByUser() {
153
- this.sessionManager.setSessionsByUser(Object.fromEntries(this.sessionsByUserId.entries()))
147
+ this.sessionManager.setSessionsByUser(
148
+ Object.fromEntries(this.sessionsByUserId.entries())
149
+ )
154
150
  }
155
151
 
156
152
  private persistUsersOrder() {
157
153
  this.sessionManager.setUsersOrder(this.usersOrder)
158
154
  }
159
155
 
156
+ private restorePersistedUsers() {
157
+ const persistedSessionsByUser = this.sessionManager.getSessionsByUser()
158
+ for (const [userId, session] of Object.entries(persistedSessionsByUser)) {
159
+ this.sessionsByUserId.set(userId, session)
160
+ }
161
+
162
+ const persistedOrder = this.sessionManager.getUsersOrder()
163
+ const nextUsersOrder: string[] = []
164
+ for (const userId of persistedOrder) {
165
+ if (!nextUsersOrder.includes(userId)) {
166
+ nextUsersOrder.push(userId)
167
+ }
168
+ }
169
+ for (const userId of this.sessionsByUserId.keys()) {
170
+ if (!nextUsersOrder.includes(userId)) {
171
+ nextUsersOrder.push(userId)
172
+ }
173
+ }
174
+ this.usersOrder = nextUsersOrder
175
+
176
+ for (const userId of this.usersOrder) {
177
+ this.getOrCreateUser(userId)
178
+ }
179
+ }
180
+
181
+ private restoreCurrentSession() {
182
+ const currentSession = this.sessionManager.get()
183
+ if (currentSession?.userId) {
184
+ this.sessionsByUserId.set(currentSession.userId, currentSession)
185
+ this.getOrCreateUser(currentSession.userId)
186
+ this.touchUser(currentSession.userId)
187
+ this.persistSessionsByUser()
188
+ return
189
+ }
190
+
191
+ this.setCurrentSessionFromOrder()
192
+ }
193
+
160
194
  private touchUser(userId: string) {
161
195
  this.usersOrder = [userId, ...this.usersOrder.filter((id) => id !== userId)]
162
196
  this.persistUsersOrder()
@@ -225,8 +259,12 @@ export class App {
225
259
  }
226
260
 
227
261
  private async bootstrapSessionOnLoad(): Promise<void> {
262
+ await this.sessionManager.whenReady()
263
+ this.restorePersistedUsers()
264
+ this.restoreCurrentSession()
265
+
228
266
  const session = this.sessionManager.get()
229
- if (!session || typeof localStorage === 'undefined') {
267
+ if (!session || !this.sessionManager.hasPersistentStorage()) {
230
268
  return
231
269
  }
232
270
 
@@ -293,17 +331,24 @@ export class App {
293
331
  }
294
332
 
295
333
  if (credentials.provider === 'custom-function') {
296
- const result = await this.postProvider<LoginResponse>('/custom-function/login', credentials.payload)
334
+ const result = await this.postProvider<LoginResponse>(
335
+ '/custom-function/login',
336
+ credentials.payload
337
+ )
297
338
  return this.setLoggedInUser(result, 'custom-function')
298
339
  }
299
340
 
300
341
  if (credentials.provider === 'custom-token') {
301
- const result = await this.postProvider<LoginResponse>('/custom-token/login', { token: credentials.token })
342
+ const result = await this.postProvider<LoginResponse>('/custom-token/login', {
343
+ token: credentials.token
344
+ })
302
345
  return this.setLoggedInUser(result, 'custom-token')
303
346
  }
304
347
 
305
348
  const unsupportedProvider: never = credentials
306
- throw new Error(`Unsupported credentials provider: ${JSON.stringify(unsupportedProvider)}`)
349
+ throw new Error(
350
+ `Unsupported credentials provider: ${JSON.stringify(unsupportedProvider)}`
351
+ )
307
352
  }
308
353
 
309
354
  switchUser(nextUser: User) {
@@ -336,13 +381,14 @@ export class App {
336
381
  }
337
382
 
338
383
  async deleteUser(user: User) {
339
- await this.requestWithAccessToken((accessToken) =>
340
- requestJson({
341
- url: this.authUrl('/delete'),
342
- method: 'DELETE',
343
- bearerToken: accessToken,
344
- timeout: this.timeout
345
- }),
384
+ await this.requestWithAccessToken(
385
+ (accessToken) =>
386
+ requestJson({
387
+ url: this.authUrl('/delete'),
388
+ method: 'DELETE',
389
+ bearerToken: accessToken,
390
+ timeout: this.timeout
391
+ }),
346
392
  user.id
347
393
  )
348
394
  await this.removeUser(user)
@@ -350,7 +396,9 @@ export class App {
350
396
 
351
397
  getSessionOrThrow(userId?: string) {
352
398
  const targetUserId = userId ?? this.currentUser?.id
353
- const session = targetUserId ? this.sessionsByUserId.get(targetUserId) : this.sessionManager.get()
399
+ const session = targetUserId
400
+ ? this.sessionsByUserId.get(targetUserId)
401
+ : this.sessionManager.get()
354
402
  if (!session) {
355
403
  throw new Error('User is not authenticated')
356
404
  }
@@ -381,7 +429,10 @@ export class App {
381
429
  })
382
430
  }
383
431
 
384
- private async requestWithAccessToken<T>(operation: (accessToken: string) => Promise<T>, userId?: string) {
432
+ private async requestWithAccessToken<T>(
433
+ operation: (accessToken: string) => Promise<T>,
434
+ userId?: string
435
+ ) {
385
436
  const firstSession = this.getSessionOrThrow(userId)
386
437
  try {
387
438
  return await operation(firstSession.accessToken)
@@ -402,21 +453,26 @@ export class App {
402
453
  arguments: args
403
454
  }
404
455
 
405
- const result = await this.requestWithAccessToken((accessToken) =>
406
- requestJson<unknown>({
407
- url: this.functionsUrl('/call'),
408
- method: 'POST',
409
- body: payload,
410
- bearerToken: accessToken,
411
- timeout: this.timeout
412
- }),
456
+ const result = await this.requestWithAccessToken(
457
+ (accessToken) =>
458
+ requestJson<unknown>({
459
+ url: this.functionsUrl(`/call?func=${name}`),
460
+ method: 'POST',
461
+ body: payload,
462
+ bearerToken: accessToken,
463
+ timeout: this.timeout
464
+ }),
413
465
  userId
414
466
  )
415
467
 
416
468
  return normalizeFunctionResponse(result)
417
469
  }
418
470
 
419
- async callFunctionStreaming(name: string, args: unknown[], userId?: string): Promise<AsyncIterable<Uint8Array>> {
471
+ async callFunctionStreaming(
472
+ name: string,
473
+ args: unknown[],
474
+ userId?: string
475
+ ): Promise<AsyncIterable<Uint8Array>> {
420
476
  await this.ensureSessionBootstrapped()
421
477
  const payload: FunctionCallPayload = {
422
478
  name,
@@ -443,7 +499,11 @@ export class App {
443
499
  timeout
444
500
  })
445
501
  } catch (error) {
446
- if (!didRefresh && error instanceof FlowerbaseHttpError && error.status === 401) {
502
+ if (
503
+ !didRefresh &&
504
+ error instanceof FlowerbaseHttpError &&
505
+ error.status === 401
506
+ ) {
447
507
  await refreshSession()
448
508
  didRefresh = true
449
509
  continue
@@ -457,7 +517,11 @@ export class App {
457
517
  }
458
518
  return
459
519
  } catch (error) {
460
- if (!didRefresh && error instanceof FlowerbaseHttpError && error.status === 401) {
520
+ if (
521
+ !didRefresh &&
522
+ error instanceof FlowerbaseHttpError &&
523
+ error.status === 401
524
+ ) {
461
525
  await refreshSession()
462
526
  didRefresh = true
463
527
  continue
@@ -469,7 +533,12 @@ export class App {
469
533
  }
470
534
  }
471
535
 
472
- async callService(name: string, args: unknown[], service = 'mongodb-atlas', userId?: string) {
536
+ async callService(
537
+ name: string,
538
+ args: MongoDbServiceArguments,
539
+ service: MongoDbServiceName = 'mongodb-atlas',
540
+ userId?: string
541
+ ) {
473
542
  await this.ensureSessionBootstrapped()
474
543
  const payload: FunctionCallPayload = {
475
544
  name,
@@ -477,27 +546,29 @@ export class App {
477
546
  arguments: args
478
547
  }
479
548
 
480
- return this.requestWithAccessToken((accessToken) =>
481
- requestJson<unknown>({
482
- url: this.functionsUrl('/call'),
483
- method: 'POST',
484
- body: payload,
485
- bearerToken: accessToken,
486
- timeout: this.timeout
487
- }),
549
+ return this.requestWithAccessToken(
550
+ (accessToken) =>
551
+ requestJson<unknown>({
552
+ url: this.functionsUrl(`/call?col=${args[0].collection}-${name}`),
553
+ method: 'POST',
554
+ body: payload,
555
+ bearerToken: accessToken,
556
+ timeout: this.timeout
557
+ }),
488
558
  userId
489
559
  )
490
560
  }
491
561
 
492
562
  async getProfile(userId?: string): Promise<ProfileData> {
493
563
  await this.ensureSessionBootstrapped()
494
- const profile = await this.requestWithAccessToken((accessToken) =>
495
- requestJson<ProfileData>({
496
- url: this.authUrl('/profile'),
497
- method: 'GET',
498
- bearerToken: accessToken,
499
- timeout: this.timeout
500
- }),
564
+ const profile = await this.requestWithAccessToken(
565
+ (accessToken) =>
566
+ requestJson<ProfileData>({
567
+ url: this.authUrl('/profile'),
568
+ method: 'GET',
569
+ bearerToken: accessToken,
570
+ timeout: this.timeout
571
+ }),
501
572
  userId
502
573
  )
503
574
  const session = this.getSessionOrThrow(userId)
package/src/mongo.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { App } from './app'
2
2
  import { EJSON } from './bson'
3
- import { CollectionLike, MongoClientLike } from './types'
3
+ import { CollectionLike, MongoClientLike, MongoDbServiceName } from './types'
4
4
  import { createWatchIterator } from './watch'
5
5
 
6
6
  const serialize = (value: unknown) => EJSON.serialize(value, { relaxed: false })
@@ -20,7 +20,7 @@ const mapResult = (value: unknown) => {
20
20
  return deserialize(value)
21
21
  }
22
22
 
23
- export const createMongoClient = (app: App, serviceName: string, userId: string): MongoClientLike => ({
23
+ export const createMongoClient = (app: App, serviceName: MongoDbServiceName, userId: string): MongoClientLike => ({
24
24
  db: (database: string) => ({
25
25
  collection: (collection: string): CollectionLike => {
26
26
  const callService = async (name: string, args: unknown[]) => {
package/src/session.ts CHANGED
@@ -1,24 +1,48 @@
1
+ import { createStorage } from './storage'
1
2
  import { SessionData } from './types'
2
3
 
3
- const memoryStore = new Map<string, string>()
4
+ const parseSession = (raw: string | null): SessionData | null => {
5
+ if (!raw) return null
4
6
 
5
- const getStorage = () => {
6
- if (typeof localStorage !== 'undefined') {
7
- return {
8
- getItem: (key: string) => localStorage.getItem(key),
9
- setItem: (key: string, value: string) => localStorage.setItem(key, value),
10
- removeItem: (key: string) => localStorage.removeItem(key)
11
- }
7
+ try {
8
+ return JSON.parse(raw) as SessionData
9
+ } catch {
10
+ return null
11
+ }
12
+ }
13
+
14
+ const parseUsersOrder = (raw: string | null) => {
15
+ if (!raw) return []
16
+
17
+ try {
18
+ const parsed = JSON.parse(raw)
19
+ if (!Array.isArray(parsed)) return []
20
+ return parsed.filter((item): item is string => typeof item === 'string')
21
+ } catch {
22
+ return []
12
23
  }
24
+ }
13
25
 
14
- return {
15
- getItem: (key: string) => memoryStore.get(key) ?? null,
16
- setItem: (key: string, value: string) => {
17
- memoryStore.set(key, value)
18
- },
19
- removeItem: (key: string) => {
20
- memoryStore.delete(key)
26
+ const parseSessionsByUser = (raw: string | null) => {
27
+ if (!raw) return {} as Record<string, SessionData>
28
+
29
+ try {
30
+ const parsed = JSON.parse(raw) as Record<string, SessionData>
31
+ const normalized: Record<string, SessionData> = {}
32
+ for (const [userId, session] of Object.entries(parsed)) {
33
+ if (
34
+ session &&
35
+ typeof session === 'object' &&
36
+ typeof session.accessToken === 'string' &&
37
+ typeof session.refreshToken === 'string' &&
38
+ typeof session.userId === 'string'
39
+ ) {
40
+ normalized[userId] = session
41
+ }
21
42
  }
43
+ return normalized
44
+ } catch {
45
+ return {} as Record<string, SessionData>
22
46
  }
23
47
  }
24
48
 
@@ -26,25 +50,35 @@ export class SessionManager {
26
50
  private readonly key: string
27
51
  private readonly usersKey: string
28
52
  private readonly sessionsKey: string
29
- private readonly storage = getStorage()
53
+ private readonly storage = createStorage()
54
+ private readonly hydrationPromise: Promise<void>
30
55
  private session: SessionData | null = null
56
+ private usersOrder: string[] = []
57
+ private sessionsByUser: Record<string, SessionData> = {}
31
58
 
32
59
  constructor(appId: string) {
33
60
  this.key = `flowerbase:${appId}:session`
34
61
  this.usersKey = `flowerbase:${appId}:users`
35
62
  this.sessionsKey = `flowerbase:${appId}:sessions`
36
- this.session = this.load()
63
+ this.session = parseSession(this.storage.getItem(this.key))
64
+ this.usersOrder = parseUsersOrder(this.storage.getItem(this.usersKey))
65
+ this.sessionsByUser = parseSessionsByUser(this.storage.getItem(this.sessionsKey))
66
+ this.hydrationPromise = this.hydrate()
37
67
  }
38
68
 
39
- load(): SessionData | null {
40
- const raw = this.storage.getItem(this.key)
41
- if (!raw) return null
69
+ private async hydrate() {
70
+ const hydrated = await this.storage.hydrate([this.key, this.usersKey, this.sessionsKey])
71
+ this.session = parseSession(hydrated[this.key] ?? null)
72
+ this.usersOrder = parseUsersOrder(hydrated[this.usersKey] ?? null)
73
+ this.sessionsByUser = parseSessionsByUser(hydrated[this.sessionsKey] ?? null)
74
+ }
42
75
 
43
- try {
44
- return JSON.parse(raw) as SessionData
45
- } catch {
46
- return null
47
- }
76
+ whenReady() {
77
+ return this.hydrationPromise
78
+ }
79
+
80
+ hasPersistentStorage() {
81
+ return this.storage.isPersistent
48
82
  }
49
83
 
50
84
  get() {
@@ -62,18 +96,11 @@ export class SessionManager {
62
96
  }
63
97
 
64
98
  getUsersOrder() {
65
- const raw = this.storage.getItem(this.usersKey)
66
- if (!raw) return []
67
- try {
68
- const parsed = JSON.parse(raw)
69
- if (!Array.isArray(parsed)) return []
70
- return parsed.filter((item): item is string => typeof item === 'string')
71
- } catch {
72
- return []
73
- }
99
+ return [...this.usersOrder]
74
100
  }
75
101
 
76
102
  setUsersOrder(order: string[]) {
103
+ this.usersOrder = [...order]
77
104
  if (order.length === 0) {
78
105
  this.storage.removeItem(this.usersKey)
79
106
  return
@@ -82,29 +109,11 @@ export class SessionManager {
82
109
  }
83
110
 
84
111
  getSessionsByUser() {
85
- const raw = this.storage.getItem(this.sessionsKey)
86
- if (!raw) return {} as Record<string, SessionData>
87
- try {
88
- const parsed = JSON.parse(raw) as Record<string, SessionData>
89
- const normalized: Record<string, SessionData> = {}
90
- for (const [userId, session] of Object.entries(parsed)) {
91
- if (
92
- session &&
93
- typeof session === 'object' &&
94
- typeof session.accessToken === 'string' &&
95
- typeof session.refreshToken === 'string' &&
96
- typeof session.userId === 'string'
97
- ) {
98
- normalized[userId] = session
99
- }
100
- }
101
- return normalized
102
- } catch {
103
- return {} as Record<string, SessionData>
104
- }
112
+ return { ...this.sessionsByUser }
105
113
  }
106
114
 
107
115
  setSessionsByUser(sessionsByUser: Record<string, SessionData>) {
116
+ this.sessionsByUser = { ...sessionsByUser }
108
117
  if (Object.keys(sessionsByUser).length === 0) {
109
118
  this.storage.removeItem(this.sessionsKey)
110
119
  return
@@ -0,0 +1,49 @@
1
+ import AsyncStorage from '@react-native-async-storage/async-storage'
2
+
3
+ type StorageSnapshot = Record<string, string | null>
4
+
5
+ export type PersistedStorage = {
6
+ isPersistent: boolean
7
+ getItem: (key: string) => string | null
8
+ setItem: (key: string, value: string) => void
9
+ removeItem: (key: string) => void
10
+ hydrate: (keys: string[]) => Promise<StorageSnapshot>
11
+ }
12
+
13
+ const memoryStore = new Map<string, string>()
14
+
15
+ const getSnapshot = (keys: string[]): StorageSnapshot =>
16
+ Object.fromEntries(keys.map((key) => [key, memoryStore.get(key) ?? null]))
17
+
18
+ export const createStorage = (): PersistedStorage => ({
19
+ isPersistent: true,
20
+ getItem: (key) => memoryStore.get(key) ?? null,
21
+ setItem: (key, value) => {
22
+ memoryStore.set(key, value)
23
+ void AsyncStorage.setItem(key, value).catch(() => {
24
+ // Ignore write failures and keep the in-memory cache alive.
25
+ })
26
+ },
27
+ removeItem: (key) => {
28
+ memoryStore.delete(key)
29
+ void AsyncStorage.removeItem(key).catch(() => {
30
+ // Ignore delete failures and keep the in-memory cache alive.
31
+ })
32
+ },
33
+ async hydrate(keys) {
34
+ try {
35
+ const entries = await AsyncStorage.multiGet(keys)
36
+ for (const [key, value] of entries) {
37
+ if (value === null) {
38
+ memoryStore.delete(key)
39
+ continue
40
+ }
41
+ memoryStore.set(key, value)
42
+ }
43
+ } catch {
44
+ // Ignore storage read failures and keep the in-memory cache alive.
45
+ }
46
+
47
+ return getSnapshot(keys)
48
+ }
49
+ })
package/src/storage.ts ADDED
@@ -0,0 +1,57 @@
1
+ type StorageSnapshot = Record<string, string | null>
2
+
3
+ export type PersistedStorage = {
4
+ isPersistent: boolean
5
+ getItem: (key: string) => string | null
6
+ setItem: (key: string, value: string) => void
7
+ removeItem: (key: string) => void
8
+ hydrate: (keys: string[]) => Promise<StorageSnapshot>
9
+ }
10
+
11
+ const memoryStore = new Map<string, string>()
12
+
13
+ const getStorage = () => {
14
+ const browserStorage = globalThis.localStorage
15
+ if (
16
+ browserStorage &&
17
+ typeof browserStorage.getItem === 'function' &&
18
+ typeof browserStorage.setItem === 'function' &&
19
+ typeof browserStorage.removeItem === 'function'
20
+ ) {
21
+ return {
22
+ getItem: (key: string) => browserStorage.getItem(key),
23
+ setItem: (key: string, value: string) => browserStorage.setItem(key, value),
24
+ removeItem: (key: string) => browserStorage.removeItem(key)
25
+ }
26
+ }
27
+
28
+ return {
29
+ getItem: (key: string) => memoryStore.get(key) ?? null,
30
+ setItem: (key: string, value: string) => {
31
+ memoryStore.set(key, value)
32
+ },
33
+ removeItem: (key: string) => {
34
+ memoryStore.delete(key)
35
+ }
36
+ }
37
+ }
38
+
39
+ export const createStorage = (): PersistedStorage => {
40
+ const browserStorage = globalThis.localStorage
41
+ const isPersistent =
42
+ !!browserStorage &&
43
+ typeof browserStorage.getItem === 'function' &&
44
+ typeof browserStorage.setItem === 'function' &&
45
+ typeof browserStorage.removeItem === 'function'
46
+ const storage = getStorage()
47
+
48
+ return {
49
+ isPersistent,
50
+ getItem: storage.getItem,
51
+ setItem: storage.setItem,
52
+ removeItem: storage.removeItem,
53
+ async hydrate(keys) {
54
+ return Object.fromEntries(keys.map((key) => [key, storage.getItem(key)]))
55
+ }
56
+ }
57
+ }