From ab723eb71d9c11defeac0a5c36ade83d5c6b1e47 Mon Sep 17 00:00:00 2001 From: Taesu Date: Wed, 9 Sep 2026 19:43:31 +0900 Subject: [PATCH] fix(expo): chunk storage values by byte size --- .changeset/safe-expo-unicode-storage.md | 5 +++ packages/expo/src/client-storage.ts | 44 +++++++++++++++++------ packages/expo/test/client-storage.test.ts | 23 ++++++++++++ 3 files changed, 62 insertions(+), 10 deletions(-) create mode 100644 .changeset/safe-expo-unicode-storage.md diff --git a/.changeset/safe-expo-unicode-storage.md b/.changeset/safe-expo-unicode-storage.md new file mode 100644 index 0000000000..21acd16c2f --- /dev/null +++ b/.changeset/safe-expo-unicode-storage.md @@ -0,0 +1,5 @@ +--- +"@better-auth/expo": patch +--- + +Store multibyte session data safely in Expo secure storage. diff --git a/packages/expo/src/client-storage.ts b/packages/expo/src/client-storage.ts index 53589ab2b0..e126e8d6b1 100644 --- a/packages/expo/src/client-storage.ts +++ b/packages/expo/src/client-storage.ts @@ -25,14 +25,41 @@ export function normalizeCookieName(name: string) { } /** - * Character budget per stored chunk. Some native stores reject large values, - * so larger strings are split across keys. This is not a byte-size guarantee. + * UTF-8 byte budget per stored chunk. Some native stores reject large values, + * so larger strings are split across keys. * * @see https://github.com/better-auth/better-auth/issues/9151 */ const STORAGE_VALUE_LIMIT = 1800; const MAX_STORAGE_CHUNKS = 100; +function getUtf8ByteLength(character: string) { + const codePoint = character.codePointAt(0) ?? 0; + if (codePoint <= 0x7f) return 1; + if (codePoint <= 0x7ff) return 2; + if (codePoint <= 0xffff) return 3; + return 4; +} + +function splitStorageValue(value: string) { + const chunks: string[] = []; + let start = 0; + let end = 0; + let bytes = 0; + for (const character of value) { + const characterBytes = getUtf8ByteLength(character); + if (bytes + characterBytes > STORAGE_VALUE_LIMIT) { + chunks.push(value.slice(start, end)); + start = end; + bytes = 0; + } + bytes += characterBytes; + end += character.length; + } + chunks.push(value.slice(start)); + return chunks; +} + /** * Marks a base key whose value is split across multiple storage keys. Legacy * markers contain only the chunk count. Current markers also identify the @@ -202,14 +229,15 @@ function getStorageWritePlan( const currentMarker = currentBaseValue?.startsWith(CHUNK_MARKER) ? parseChunkMarker(currentBaseValue) : null; - if (value.length <= STORAGE_VALUE_LIMIT) { + const chunks = splitStorageValue(value); + if (chunks.length === 1) { return { writes: [[key, value]], cleanup: getUnusedChunkRanges(key, currentMarker, null), }; } - const count = Math.ceil(value.length / STORAGE_VALUE_LIMIT); + const count = chunks.length; if (count > MAX_STORAGE_CHUNKS) { throw new Error( `Storage value requires ${count} chunks, exceeding the limit of ${MAX_STORAGE_CHUNKS}`, @@ -230,12 +258,8 @@ function getStorageWritePlan( serializeChunkMarker({ ...currentMarker, fallbackCount: null }), ]); } - for (let i = 0; i < count; i++) { - const start = i * STORAGE_VALUE_LIMIT; - writes.push([ - getChunkKey(key, marker, i), - value.slice(start, start + STORAGE_VALUE_LIMIT), - ]); + for (const [index, chunk] of chunks.entries()) { + writes.push([getChunkKey(key, marker, index), chunk]); } writes.push([key, serializeChunkMarker(marker)]); return { writes, cleanup: getUnusedChunkRanges(key, currentMarker, marker) }; diff --git a/packages/expo/test/client-storage.test.ts b/packages/expo/test/client-storage.test.ts index 7a870a5d60..c31e82db0d 100644 --- a/packages/expo/test/client-storage.test.ts +++ b/packages/expo/test/client-storage.test.ts @@ -90,6 +90,29 @@ it("should round-trip a value larger than the per-write storage limit", async () expect(storedValue).toBe(large); }); +/** + * @see https://docs.expo.dev/versions/latest/sdk/securestore/#securestoresetitemasynckey-value-options + */ +it("should keep Unicode chunks within the storage byte limit", () => { + const map = new Map(); + const storage = storageAdapter({ + getItem: (name) => map.get(name) ?? null, + setItem: (name, value) => map.set(name, value), + getItemAsync: async (name) => map.get(name) ?? null, + setItemAsync: async (name, value) => { + map.set(name, value); + }, + }); + const value = `${"x".repeat(1_799)}🔐${"🙂".repeat(1_000)}`; + + storage.setItem("better-auth_cookie", value); + + for (const chunk of map.values()) { + expect(new TextEncoder().encode(chunk).length).toBeLessThanOrEqual(1_800); + } + expect(storage.getItem("better-auth_cookie")).toBe(value); +}); + it("should store a value within the limit under the base key unchanged", () => { const map = new Map(); const storage = storageAdapter({