fix(expo): chunk storage values by byte size

This commit is contained in:
Taesu
2026-09-09 20:44:52 +09:00
parent 08fd4751c8
commit ab723eb71d
3 changed files with 62 additions and 10 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@better-auth/expo": patch
---
Store multibyte session data safely in Expo secure storage.
+34 -10
View File
@@ -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) };
+23
View File
@@ -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<string, string>();
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<string, string>();
const storage = storageAdapter({