From 456b5b38515b149fc306a2ebf184bff52a7758fc Mon Sep 17 00:00:00 2001 From: Yarchik Date: Mon, 25 May 2026 16:31:22 +0100 Subject: [PATCH] fix(db): fall back to crypto.getRandomValues when randomUUID is unavailable `crypto.randomUUID()` is restricted to secure contexts in browsers, so it is unavailable on pages served over plain HTTP from a non-localhost host, for example a dev server reached via a LAN IP. `@tanstack/db` was calling it directly from transactions, mutations, local-storage, local-only and the default collection-id path, so the first write would throw `TypeError: crypto.randomUUID is not a function` before user code could handle it. Centralise UUID generation in `safeRandomUUID()` (in `utils.ts`) which: - delegates to `crypto.randomUUID()` when present (the common case), - falls back to RFC 4122 v4 generation via `crypto.getRandomValues()` when it is not, with the version/variant bits set per spec, - throws an explicit Error if neither Web Crypto API is available. Replace the seven direct call sites with `safeRandomUUID()` and add unit tests covering the delegate path, the fallback path (validating a v4 shape), uniqueness across calls in fallback mode, and both error paths when crypto is missing or partial. Closes #1541 --- .../safe-random-uuid-non-secure-context.md | 5 ++ packages/db/src/collection/index.ts | 3 +- packages/db/src/collection/mutations.ts | 7 +- packages/db/src/local-only.ts | 3 +- packages/db/src/local-storage.ts | 3 +- packages/db/src/transactions.ts | 3 +- packages/db/src/utils.ts | 50 ++++++++++++++ packages/db/tests/safe-random-uuid.test.ts | 66 +++++++++++++++++++ 8 files changed, 133 insertions(+), 7 deletions(-) create mode 100644 .changeset/safe-random-uuid-non-secure-context.md create mode 100644 packages/db/tests/safe-random-uuid.test.ts diff --git a/.changeset/safe-random-uuid-non-secure-context.md b/.changeset/safe-random-uuid-non-secure-context.md new file mode 100644 index 0000000000..969ba33c79 --- /dev/null +++ b/.changeset/safe-random-uuid-non-secure-context.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +Fix `@tanstack/db` throwing `TypeError: crypto.randomUUID is not a function` in non-secure browser contexts. `crypto.randomUUID()` is restricted to secure contexts, so pages served over plain HTTP from a non-localhost host (such as a dev server reached via a LAN IP) could not insert into a collection, run a mutation, or open a transaction. UUID generation now centralises in `safeRandomUUID()` which prefers `crypto.randomUUID()` when available and falls back to RFC 4122 v4 via `crypto.getRandomValues()`. diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index e51eb998d6..7020a84a54 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -3,6 +3,7 @@ import { CollectionRequiresConfigError, CollectionRequiresSyncConfigError, } from '../errors' +import { safeRandomUUID } from '../utils' import { currentStateAsChanges } from './change-events' import { CollectionStateManager } from './state' @@ -329,7 +330,7 @@ export class CollectionImpl< if (config.id) { this.id = config.id } else { - this.id = crypto.randomUUID() + this.id = safeRandomUUID() } // Set default values for optional config properties diff --git a/packages/db/src/collection/mutations.ts b/packages/db/src/collection/mutations.ts index 765e409ef6..6148a86600 100644 --- a/packages/db/src/collection/mutations.ts +++ b/packages/db/src/collection/mutations.ts @@ -17,6 +17,7 @@ import { UndefinedKeyError, UpdateKeyNotFoundError, } from '../errors' +import { safeRandomUUID } from '../utils' import { DIRECT_TRANSACTION_METADATA_KEY } from './transaction-metadata.js' import type { Collection, CollectionImpl } from './index.js' import type { StandardSchemaV1 } from '@standard-schema/spec' @@ -193,7 +194,7 @@ export class CollectionMutationsManager< const globalKey = this.generateGlobalKey(key, item) const mutation: PendingMutation = { - mutationId: crypto.randomUUID(), + mutationId: safeRandomUUID(), original: {}, modified: validatedData, // Pick the values from validatedData based on what's passed in - this is for cases @@ -366,7 +367,7 @@ export class CollectionMutationsManager< const globalKey = this.generateGlobalKey(modifiedItemId, modifiedItem) return { - mutationId: crypto.randomUUID(), + mutationId: safeRandomUUID(), original: originalItem, modified: modifiedItem, // Pick the values from modifiedItem based on what's passed in - this is for cases @@ -497,7 +498,7 @@ export class CollectionMutationsManager< `delete`, CollectionImpl > = { - mutationId: crypto.randomUUID(), + mutationId: safeRandomUUID(), original: this.state.get(key)!, modified: this.state.get(key)!, changes: this.state.get(key)!, diff --git a/packages/db/src/local-only.ts b/packages/db/src/local-only.ts index d3a0a7f2ca..db2d4c8bb2 100644 --- a/packages/db/src/local-only.ts +++ b/packages/db/src/local-only.ts @@ -1,3 +1,4 @@ +import { safeRandomUUID } from './utils' import type { BaseCollectionConfig, CollectionConfig, @@ -182,7 +183,7 @@ export function localOnlyCollectionOptions< const { initialData, onInsert, onUpdate, onDelete, id, ...restConfig } = config - const collectionId = id ?? crypto.randomUUID() + const collectionId = id ?? safeRandomUUID() // Create the sync configuration with transaction confirmation capability const syncResult = createLocalOnlySync(initialData) diff --git a/packages/db/src/local-storage.ts b/packages/db/src/local-storage.ts index 3060b7ec61..c0cd354b17 100644 --- a/packages/db/src/local-storage.ts +++ b/packages/db/src/local-storage.ts @@ -4,6 +4,7 @@ import { SerializationError, StorageKeyRequiredError, } from './errors' +import { safeRandomUUID } from './utils' import type { BaseCollectionConfig, CollectionConfig, @@ -149,7 +150,7 @@ function validateJsonSerializable( * @returns A unique identifier string for tracking data versions */ function generateUuid(): string { - return crypto.randomUUID() + return safeRandomUUID() } /** diff --git a/packages/db/src/transactions.ts b/packages/db/src/transactions.ts index 84e2bb0d5d..9f88987831 100644 --- a/packages/db/src/transactions.ts +++ b/packages/db/src/transactions.ts @@ -7,6 +7,7 @@ import { TransactionNotPendingMutateError, } from './errors' import { transactionScopedScheduler } from './scheduler.js' +import { safeRandomUUID } from './utils' import type { Deferred } from './deferred' import type { MutationFn, @@ -224,7 +225,7 @@ class Transaction> { if (typeof config.mutationFn === `undefined`) { throw new MissingMutationFunctionError() } - this.id = config.id ?? crypto.randomUUID() + this.id = config.id ?? safeRandomUUID() this.mutationFn = config.mutationFn this.state = `pending` this.mutations = [] diff --git a/packages/db/src/utils.ts b/packages/db/src/utils.ts index e652087419..52bc3d9473 100644 --- a/packages/db/src/utils.ts +++ b/packages/db/src/utils.ts @@ -240,3 +240,53 @@ export const DEFAULT_COMPARE_OPTIONS: CompareOptions = { nulls: `first`, stringSort: `locale`, } + +/** + * Returns a UUID v4 string. Prefers `crypto.randomUUID()` when available and + * falls back to RFC 4122 v4 generation via `crypto.getRandomValues()` when it + * is not. + * + * Background: `crypto.randomUUID()` is restricted to secure contexts in + * browsers, so it is unavailable on pages served over plain HTTP from a + * non-localhost host (e.g. a dev server reached via a LAN IP). In those + * environments `crypto.getRandomValues()` remains available and is enough to + * produce a spec-compliant UUID v4. + * + * @throws Error when neither `crypto.randomUUID()` nor + * `crypto.getRandomValues()` is available. + */ +export function safeRandomUUID(): string { + const c = globalThis.crypto as + | (Crypto & { randomUUID?: () => string }) + | undefined + if (c?.randomUUID) { + return c.randomUUID() + } + if (c?.getRandomValues) { + const bytes = new Uint8Array(16) + c.getRandomValues(bytes) + // RFC 4122 ยง4.4: set the four most significant bits of the 7th byte to + // 0100 (version 4) and the two most significant bits of the 9th byte to + // 10 (variant 10xx). + bytes[6] = (bytes[6]! & 0x0f) | 0x40 + bytes[8] = (bytes[8]! & 0x3f) | 0x80 + const hex: Array = [] + for (let i = 0; i < bytes.length; i++) { + hex.push(bytes[i]!.toString(16).padStart(2, `0`)) + } + return ( + hex.slice(0, 4).join(``) + + `-` + + hex.slice(4, 6).join(``) + + `-` + + hex.slice(6, 8).join(``) + + `-` + + hex.slice(8, 10).join(``) + + `-` + + hex.slice(10, 16).join(``) + ) + } + throw new Error( + `@tanstack/db: UUID generation requires Web Crypto (crypto.randomUUID or crypto.getRandomValues)`, + ) +} diff --git a/packages/db/tests/safe-random-uuid.test.ts b/packages/db/tests/safe-random-uuid.test.ts new file mode 100644 index 0000000000..952656c78a --- /dev/null +++ b/packages/db/tests/safe-random-uuid.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { safeRandomUUID } from '../src/utils' + +const UUID_V4_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + +describe(`safeRandomUUID`, () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it(`delegates to crypto.randomUUID when available`, () => { + const randomUUID = vi.fn(() => `11111111-2222-4333-8444-555555555555`) + vi.stubGlobal(`crypto`, { + randomUUID, + getRandomValues: (arr: Uint8Array) => arr, + }) + + expect(safeRandomUUID()).toBe(`11111111-2222-4333-8444-555555555555`) + expect(randomUUID).toHaveBeenCalledOnce() + }) + + it(`falls back to crypto.getRandomValues when randomUUID is missing`, () => { + const getRandomValues = vi.fn((arr: Uint8Array) => { + for (let i = 0; i < arr.length; i++) arr[i] = i + return arr + }) + vi.stubGlobal(`crypto`, { randomUUID: undefined, getRandomValues }) + + const uuid = safeRandomUUID() + + expect(uuid).toMatch(UUID_V4_RE) + expect(getRandomValues).toHaveBeenCalledOnce() + }) + + it(`produces distinct UUIDs across calls when using the fallback`, () => { + let counter = 0 + vi.stubGlobal(`crypto`, { + randomUUID: undefined, + getRandomValues: (arr: Uint8Array) => { + for (let i = 0; i < arr.length; i++) arr[i] = (counter + i) & 0xff + counter++ + return arr + }, + }) + + const first = safeRandomUUID() + const second = safeRandomUUID() + + expect(first).toMatch(UUID_V4_RE) + expect(second).toMatch(UUID_V4_RE) + expect(first).not.toBe(second) + }) + + it(`throws when neither randomUUID nor getRandomValues is available`, () => { + vi.stubGlobal(`crypto`, {}) + + expect(() => safeRandomUUID()).toThrowError(/Web Crypto/) + }) + + it(`throws when crypto itself is undefined`, () => { + vi.stubGlobal(`crypto`, undefined) + + expect(() => safeRandomUUID()).toThrowError(/Web Crypto/) + }) +})