From 9d4e8ec838de21ee9456aad8c27f6b712f5cf305 Mon Sep 17 00:00:00 2001 From: "Usman S." Date: Fri, 29 Aug 2025 14:16:35 +0000 Subject: [PATCH 1/8] feat: Add UUID parsing functionality with version support --- packages/nuqs/src/parsers.test.ts | 29 ++++++++++++++++++- packages/nuqs/src/parsers.ts | 48 +++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/packages/nuqs/src/parsers.test.ts b/packages/nuqs/src/parsers.test.ts index ad186b36a..3de50536f 100644 --- a/packages/nuqs/src/parsers.test.ts +++ b/packages/nuqs/src/parsers.test.ts @@ -16,7 +16,8 @@ import { parseAsString, parseAsStringEnum, parseAsStringLiteral, - parseAsTimestamp + parseAsTimestamp, + parseAsUuid } from './parsers' import { isParserBijective, @@ -30,6 +31,32 @@ describe('parsers', () => { expect(parseAsString.parse('foo')).toBe('foo') expect(isParserBijective(parseAsString, 'foo', 'foo')).toBe(true) }) + it('parseAsUuid', () => { + expect(parseAsUuid().parse('')).toBeNull() + expect(parseAsUuid().parse('foo')).toBeNull() + + expect(parseAsUuid().parse('3c1b65c0-84de-11f0-a3d8-b511344ab1d8')).toBe( + '3c1b65c0-84de-11f0-a3d8-b511344ab1d8' + ) + + expect( + parseAsUuid({ version: 1 }).parse('3c1b65c0-84de-11f0-a3d8-b511344ab1d8') + ).toBe('3c1b65c0-84de-11f0-a3d8-b511344ab1d8') // V1 + expect( + parseAsUuid({ version: 4 }).parse('067431d0-1d24-438c-a25d-42607d85495d') + ).toBe('067431d0-1d24-438c-a25d-42607d85495d') // V4 + expect( + parseAsUuid({ version: 7 }).parse('0198f612-4e10-74ad-babb-e9c3c0a46984') + ).toBe('0198f612-4e10-74ad-babb-e9c3c0a46984') // V7 + + expect( + isParserBijective( + parseAsUuid(), + '3c1b65c0-84de-11f0-a3d8-b511344ab1d8', + '3c1b65c0-84de-11f0-a3d8-b511344ab1d8' + ) + ).toBe(true) + }) it('parseAsInteger', () => { expect(parseAsInteger.parse('')).toBeNull() expect(parseAsInteger.parse('1')).toBe(1) diff --git a/packages/nuqs/src/parsers.ts b/packages/nuqs/src/parsers.ts index c85580615..85b740c2f 100644 --- a/packages/nuqs/src/parsers.ts +++ b/packages/nuqs/src/parsers.ts @@ -247,6 +247,54 @@ export const parseAsIsoDate: ParserBuilder = createParser({ eq: compareDates }) +/** + * Parse and validate UUID strings from the query string. + * + * By default, accepts any valid UUID format (v1-v8) including the special + * nil UUID (all zeros) and max UUID (all Fs). You can optionally specify + * a specific UUID version to validate against. + * + * @param opts - Optional configuration object + * @param opts.version - Specific UUID version to validate (1-8) + * @returns A parser that validates UUID strings + * + * @example + * ```ts + * // Accept any valid UUID + * const [id, setId] = useQueryState('id', parseAsUuid()) + * + * // URL: ?id=550e8400-e29b-41d4-a716-446655440000 + * console.log(id) // "550e8400-e29b-41d4-a716-446655440000" + * ``` + * + * @example + * ```ts + * // Only accept UUID v4 + * const [sessionId, setSessionId] = useQueryState( + * 'sessionId', + * parseAsUuid({ version: 4 }).withDefault('00000000-0000-0000-0000-000000000000') + * ) + * + * // URL: ?sessionId=f47ac10b-58cc-4372-a567-0e02b2c3d479 + * console.log(sessionId) // "f47ac10b-58cc-4372-a567-0e02b2c3d479" + * ``` + */ +export function parseAsUuid(opts?: { version: number }): ParserBuilder { + return createParser({ + parse: v => { + let uuidRegex = + /^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/ + if (opts?.version) { + uuidRegex = new RegExp( + `^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-${opts.version}[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$` + ) + } + return uuidRegex.test(v) ? v : null + }, + serialize: String + }) +} + /** * String-based enums provide better type-safety for known sets of values. * You will need to pass the parseAsStringEnum function a list of your enum values From ddf7ef5f997aff3bd6c77792fc280a5ef38efee4 Mon Sep 17 00:00:00 2001 From: "Usman S." Date: Fri, 29 Aug 2025 15:28:37 +0000 Subject: [PATCH 2/8] fix: Update parseAsUuid to allow optional version parameter --- packages/nuqs/src/parsers.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/nuqs/src/parsers.ts b/packages/nuqs/src/parsers.ts index 85b740c2f..e100dcacc 100644 --- a/packages/nuqs/src/parsers.ts +++ b/packages/nuqs/src/parsers.ts @@ -279,7 +279,9 @@ export const parseAsIsoDate: ParserBuilder = createParser({ * console.log(sessionId) // "f47ac10b-58cc-4372-a567-0e02b2c3d479" * ``` */ -export function parseAsUuid(opts?: { version: number }): ParserBuilder { +export function parseAsUuid(opts?: { + version?: number +}): ParserBuilder { return createParser({ parse: v => { let uuidRegex = From 05035505ca87ab15b9512261a5de9a32995633fe Mon Sep 17 00:00:00 2001 From: "Usman S." Date: Fri, 29 Aug 2025 15:28:57 +0000 Subject: [PATCH 3/8] feat: Add UUID parser demo and documentation for UUID validation --- .../docs/content/docs/parsers/built-in.mdx | 30 ++++++++++++++++++- packages/docs/content/docs/parsers/demos.tsx | 23 +++++++++++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/packages/docs/content/docs/parsers/built-in.mdx b/packages/docs/content/docs/parsers/built-in.mdx index e9da3ead3..39a8753d1 100644 --- a/packages/docs/content/docs/parsers/built-in.mdx +++ b/packages/docs/content/docs/parsers/built-in.mdx @@ -15,7 +15,8 @@ import { DateISOParserDemo, DatetimeISOParserDemo, DateTimestampParserDemo, - JsonParserDemo + JsonParserDemo, + UuidParserDemo } from '@/content/docs/parsers/demos' Search params are strings by default, but chances are your state is more complex than that. @@ -58,6 +59,33 @@ export const searchParamsParsers = { } ``` +## UUID + +Validates and parses UUID strings. Accepts all valid UUID formats by default, or you can specify a particular version. + +```ts +import { parseAsUuid } from 'nuqs' + +// Accept any valid UUID +const [id, setId] = useQueryState('id', parseAsUuid()) + +// Only accept UUID v4 +const [sessionId, setSessionId] = useQueryState( + 'sessionId', + parseAsUuid({ version: 4 }) +) +``` + +}> + + + + +When no version is specified, the parser accepts any valid UUID format (versions 1-8). +When a specific version is provided, only UUIDs of that version will be accepted. +Invalid UUIDs will result in a `null` value. + + ## Numbers ### Integers diff --git a/packages/docs/content/docs/parsers/demos.tsx b/packages/docs/content/docs/parsers/demos.tsx index be21a181c..f8be288e7 100644 --- a/packages/docs/content/docs/parsers/demos.tsx +++ b/packages/docs/content/docs/parsers/demos.tsx @@ -27,11 +27,12 @@ import { parseAsIsoDate, parseAsIsoDateTime, parseAsJson, + parseAsUuid, parseAsStringLiteral, parseAsTimestamp, useQueryState } from 'nuqs' -import React from 'react' +import React, { useState } from 'react' import { z } from 'zod' export function DemoFallback() { @@ -109,6 +110,26 @@ export function StringParserDemo() { ) } +export function UuidParserDemo() { + const [value, setValue] = useQueryState('uuid', parseAsUuid()) + + return ( + +
+        {value || 'null'}
+      
+ + +
+ ) +} + export function IntegerParserDemo() { const [value, setValue] = useQueryState('int', parseAsInteger) return ( From 1bc0eba9d81035625dc80bb99937e683fb9c0f44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Fri, 29 Aug 2025 20:22:23 +0200 Subject: [PATCH 4/8] chore: Bump up the size limit to 6kB --- packages/nuqs/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nuqs/package.json b/packages/nuqs/package.json index 9e543ee6c..3b4798659 100644 --- a/packages/nuqs/package.json +++ b/packages/nuqs/package.json @@ -196,7 +196,7 @@ { "name": "Client", "path": "dist/index.js", - "limit": "5.5 kB", + "limit": "6 kB", "ignore": [ "react", "next" From e70f52862338cae5656cbafbbb93777aec6deb38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Fri, 29 Aug 2025 21:50:24 +0200 Subject: [PATCH 5/8] fix: Declare parseAsUuid in the exported API --- packages/nuqs/src/api.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/nuqs/src/api.test.ts b/packages/nuqs/src/api.test.ts index b569a92eb..feb5a63a9 100644 --- a/packages/nuqs/src/api.test.ts +++ b/packages/nuqs/src/api.test.ts @@ -27,6 +27,7 @@ const exports = ` "parseAsStringEnum": "function", "parseAsStringLiteral": "function", "parseAsTimestamp": "object", + "parseAsUuid": "function", "throttle": "function", "useQueryState": "function", "useQueryStates": "function", @@ -93,6 +94,7 @@ const exports = ` "parseAsStringEnum": "function", "parseAsStringLiteral": "function", "parseAsTimestamp": "object", + "parseAsUuid": "function", "throttle": "function", }, "./testing": { From 84f2b57acc7757e5b4fb491b988be4f818801042 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Fri, 29 Aug 2025 21:52:16 +0200 Subject: [PATCH 6/8] test: Add type testing --- packages/nuqs/tests/parsers.test-d.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/nuqs/tests/parsers.test-d.ts b/packages/nuqs/tests/parsers.test-d.ts index 443abd577..324b752a2 100644 --- a/packages/nuqs/tests/parsers.test-d.ts +++ b/packages/nuqs/tests/parsers.test-d.ts @@ -13,6 +13,7 @@ import { parseAsStringEnum, parseAsStringLiteral, parseAsTimestamp, + parseAsUuid, type inferParserType } from '../dist' @@ -65,6 +66,18 @@ describe('types/parsers', () => { assertType(p.serialize(new Date())) assertType(p.parseServerSide(undefined)) }) + test('parseAsUuid (no version specified)', () => { + const p = parseAsUuid() + assertType(p.parse('550e8400-e29b-41d4-a716-446655440000')) + assertType(p.serialize('550e8400-e29b-41d4-a716-446655440000')) + assertType(p.parseServerSide(undefined)) + }) + test('parseAsUuid (with version specified)', () => { + const p = parseAsUuid({ version: 4 }) + assertType(p.parse('550e8400-e29b-41d4-a716-446655440000')) + assertType(p.serialize('550e8400-e29b-41d4-a716-446655440000')) + assertType(p.parseServerSide(undefined)) + }) test('parseAsStringEnum', () => { enum Test { A = 'a', From bef5401a7ca4d73ea85a9b706c25f81a41409c72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Fri, 29 Aug 2025 22:09:47 +0200 Subject: [PATCH 7/8] fix: Use number literal for version numbers --- packages/nuqs/src/parsers.ts | 2 +- packages/nuqs/tests/parsers.test-d.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/nuqs/src/parsers.ts b/packages/nuqs/src/parsers.ts index e100dcacc..b48bb3fdf 100644 --- a/packages/nuqs/src/parsers.ts +++ b/packages/nuqs/src/parsers.ts @@ -280,7 +280,7 @@ export const parseAsIsoDate: ParserBuilder = createParser({ * ``` */ export function parseAsUuid(opts?: { - version?: number + version?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 }): ParserBuilder { return createParser({ parse: v => { diff --git a/packages/nuqs/tests/parsers.test-d.ts b/packages/nuqs/tests/parsers.test-d.ts index 324b752a2..8b9e99284 100644 --- a/packages/nuqs/tests/parsers.test-d.ts +++ b/packages/nuqs/tests/parsers.test-d.ts @@ -78,6 +78,10 @@ describe('types/parsers', () => { assertType(p.serialize('550e8400-e29b-41d4-a716-446655440000')) assertType(p.parseServerSide(undefined)) }) + test('parseasUuid (with invalid version specified)', () => { + // @ts-expect-error + parseAsUuid({ version: 123 }) + }) test('parseAsStringEnum', () => { enum Test { A = 'a', From 60ae4c06804278f490ae3b7a70d3dd77c2ad05f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Fri, 29 Aug 2025 23:29:23 +0200 Subject: [PATCH 8/8] chore: Reduce bundle size, protect againt ReDoS --- packages/nuqs/src/parsers.test.ts | 57 +++++++++++++++------------ packages/nuqs/src/parsers.ts | 21 +++++----- packages/nuqs/tests/parsers.test-d.ts | 2 +- 3 files changed, 44 insertions(+), 36 deletions(-) diff --git a/packages/nuqs/src/parsers.test.ts b/packages/nuqs/src/parsers.test.ts index 3de50536f..b7cfd5a74 100644 --- a/packages/nuqs/src/parsers.test.ts +++ b/packages/nuqs/src/parsers.test.ts @@ -31,32 +31,6 @@ describe('parsers', () => { expect(parseAsString.parse('foo')).toBe('foo') expect(isParserBijective(parseAsString, 'foo', 'foo')).toBe(true) }) - it('parseAsUuid', () => { - expect(parseAsUuid().parse('')).toBeNull() - expect(parseAsUuid().parse('foo')).toBeNull() - - expect(parseAsUuid().parse('3c1b65c0-84de-11f0-a3d8-b511344ab1d8')).toBe( - '3c1b65c0-84de-11f0-a3d8-b511344ab1d8' - ) - - expect( - parseAsUuid({ version: 1 }).parse('3c1b65c0-84de-11f0-a3d8-b511344ab1d8') - ).toBe('3c1b65c0-84de-11f0-a3d8-b511344ab1d8') // V1 - expect( - parseAsUuid({ version: 4 }).parse('067431d0-1d24-438c-a25d-42607d85495d') - ).toBe('067431d0-1d24-438c-a25d-42607d85495d') // V4 - expect( - parseAsUuid({ version: 7 }).parse('0198f612-4e10-74ad-babb-e9c3c0a46984') - ).toBe('0198f612-4e10-74ad-babb-e9c3c0a46984') // V7 - - expect( - isParserBijective( - parseAsUuid(), - '3c1b65c0-84de-11f0-a3d8-b511344ab1d8', - '3c1b65c0-84de-11f0-a3d8-b511344ab1d8' - ) - ).toBe(true) - }) it('parseAsInteger', () => { expect(parseAsInteger.parse('')).toBeNull() expect(parseAsInteger.parse('1')).toBe(1) @@ -162,6 +136,37 @@ describe('parsers', () => { expect(testSerializeThenParse(parseAsIsoDate, ref)).toBe(true) expect(isParserBijective(parseAsIsoDate, moment, ref)).toBe(true) }) + it('parseAsUuid', () => { + expect(parseAsUuid().parse('')).toBeNull() + expect(parseAsUuid().parse('foo')).toBeNull() + const uuid = (v = 4) => `01234567-890a-${v}${v}${v}${v}-8bcd-ef0123456789` + expect(parseAsUuid().parse(uuid())).toBe(uuid()) + expect(parseAsUuid({ version: 1 }).parse(uuid(1))).toBe(uuid(1)) + expect(parseAsUuid({ version: 1 }).parse(uuid(2))).toBe(null) + expect(parseAsUuid({ version: 2 }).parse(uuid(2))).toBe(uuid(2)) + expect(parseAsUuid({ version: 2 }).parse(uuid(1))).toBe(null) + expect(parseAsUuid({ version: 3 }).parse(uuid(3))).toBe(uuid(3)) + expect(parseAsUuid({ version: 3 }).parse(uuid(1))).toBe(null) + expect(parseAsUuid({ version: 4 }).parse(uuid(4))).toBe(uuid(4)) + expect(parseAsUuid({ version: 4 }).parse(uuid(1))).toBe(null) + expect(parseAsUuid({ version: 5 }).parse(uuid(5))).toBe(uuid(5)) + expect(parseAsUuid({ version: 5 }).parse(uuid(1))).toBe(null) + expect(parseAsUuid({ version: 6 }).parse(uuid(6))).toBe(uuid(6)) + expect(parseAsUuid({ version: 6 }).parse(uuid(1))).toBe(null) + expect(parseAsUuid({ version: 7 }).parse(uuid(7))).toBe(uuid(7)) + expect(parseAsUuid({ version: 7 }).parse(uuid(1))).toBe(null) + expect(parseAsUuid({ version: 8 }).parse(uuid(8))).toBe(uuid(8)) + expect(parseAsUuid({ version: 8 }).parse(uuid(1))).toBe(null) + + expect(parseAsUuid().parse('00000000-0000-0000-0000-000000000000')).toBe( + '00000000-0000-0000-0000-000000000000' + ) + expect(parseAsUuid().parse('ffffffff-ffff-ffff-ffff-ffffffffffff')).toBe( + 'ffffffff-ffff-ffff-ffff-ffffffffffff' + ) + + expect(isParserBijective(parseAsUuid(), uuid(), uuid())).toBe(true) + }) it('parseAsStringEnum', () => { enum Test { A = 'a', diff --git a/packages/nuqs/src/parsers.ts b/packages/nuqs/src/parsers.ts index b48bb3fdf..9e27ca645 100644 --- a/packages/nuqs/src/parsers.ts +++ b/packages/nuqs/src/parsers.ts @@ -283,15 +283,18 @@ export function parseAsUuid(opts?: { version?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 }): ParserBuilder { return createParser({ - parse: v => { - let uuidRegex = - /^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/ - if (opts?.version) { - uuidRegex = new RegExp( - `^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-${opts.version}[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$` - ) - } - return uuidRegex.test(v) ? v : null + parse(query) { + // Create regex only when needed to reduce bundle size + const v = opts?.version + // Note: using +v to coerce to number (in case of user-controlled inputs, + // to avoid a RegExp DoS attack) + const versionPattern = v ? `[${+v}]` : '[1-8]' + return new RegExp( + `^([0-9a-f]{8}-[0-9a-f]{4}-${versionPattern}[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|0{8}-0{4}-0{4}-0{4}-0{12}|f{8}-f{4}-f{4}-f{4}-f{12})$`, + 'i' + ).test(query) + ? query + : null }, serialize: String }) diff --git a/packages/nuqs/tests/parsers.test-d.ts b/packages/nuqs/tests/parsers.test-d.ts index 8b9e99284..708cf3050 100644 --- a/packages/nuqs/tests/parsers.test-d.ts +++ b/packages/nuqs/tests/parsers.test-d.ts @@ -78,7 +78,7 @@ describe('types/parsers', () => { assertType(p.serialize('550e8400-e29b-41d4-a716-446655440000')) assertType(p.parseServerSide(undefined)) }) - test('parseasUuid (with invalid version specified)', () => { + test('parseAsUuid (with invalid version specified)', () => { // @ts-expect-error parseAsUuid({ version: 123 }) })