Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/safe-random-uuid-non-secure-context.md
Original file line number Diff line number Diff line change
@@ -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()`.
3 changes: 2 additions & 1 deletion packages/db/src/collection/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
CollectionRequiresConfigError,
CollectionRequiresSyncConfigError,
} from '../errors'
import { safeRandomUUID } from '../utils'
import { currentStateAsChanges } from './change-events'

import { CollectionStateManager } from './state'
Expand Down Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions packages/db/src/collection/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -193,7 +194,7 @@ export class CollectionMutationsManager<
const globalKey = this.generateGlobalKey(key, item)

const mutation: PendingMutation<TOutput, `insert`> = {
mutationId: crypto.randomUUID(),
mutationId: safeRandomUUID(),
original: {},
modified: validatedData,
// Pick the values from validatedData based on what's passed in - this is for cases
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -497,7 +498,7 @@ export class CollectionMutationsManager<
`delete`,
CollectionImpl<TOutput, TKey, TUtils, TSchema, TInput>
> = {
mutationId: crypto.randomUUID(),
mutationId: safeRandomUUID(),
original: this.state.get(key)!,
modified: this.state.get(key)!,
changes: this.state.get(key)!,
Expand Down
3 changes: 2 additions & 1 deletion packages/db/src/local-only.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { safeRandomUUID } from './utils'
import type {
BaseCollectionConfig,
CollectionConfig,
Expand Down Expand Up @@ -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<T, TKey>(initialData)
Expand Down
3 changes: 2 additions & 1 deletion packages/db/src/local-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
SerializationError,
StorageKeyRequiredError,
} from './errors'
import { safeRandomUUID } from './utils'
import type {
BaseCollectionConfig,
CollectionConfig,
Expand Down Expand Up @@ -149,7 +150,7 @@ function validateJsonSerializable(
* @returns A unique identifier string for tracking data versions
*/
function generateUuid(): string {
return crypto.randomUUID()
return safeRandomUUID()
}

/**
Expand Down
3 changes: 2 additions & 1 deletion packages/db/src/transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -224,7 +225,7 @@ class Transaction<T extends object = Record<string, unknown>> {
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 = []
Expand Down
50 changes: 50 additions & 0 deletions packages/db/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = []
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)`,
)
}
66 changes: 66 additions & 0 deletions packages/db/tests/safe-random-uuid.test.ts
Original file line number Diff line number Diff line change
@@ -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/)
})
})