hanzio 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +510 -0
  2. package/dist/api-wrapper/index.d.ts +84 -0
  3. package/dist/api-wrapper/index.js +250 -0
  4. package/dist/array/index.d.ts +22 -0
  5. package/dist/cache/index.d.ts +19 -0
  6. package/dist/date/index.d.ts +11 -0
  7. package/dist/gzip/index.d.ts +2 -0
  8. package/dist/gzip/index.js +16 -0
  9. package/dist/index.d.ts +12 -0
  10. package/dist/index.js +1565 -0
  11. package/dist/jwt/index.d.ts +3 -0
  12. package/dist/jwt/index.js +19 -0
  13. package/dist/math/index.d.ts +2 -0
  14. package/dist/math/linearRegressionTrend.d.ts +5 -0
  15. package/dist/math/metricsSeriesColors.d.ts +42 -0
  16. package/dist/p-queue/index.d.ts +95 -0
  17. package/dist/p-queue/index.js +439 -0
  18. package/dist/promise/index.d.ts +21 -0
  19. package/dist/sitemap/index.d.ts +14 -0
  20. package/dist/sitemap/index.js +552 -0
  21. package/dist/state/index.d.ts +101 -0
  22. package/dist/string/index.d.ts +8 -0
  23. package/dist/typedswitch/index.d.ts +124 -0
  24. package/dist/url/index.d.ts +1 -0
  25. package/dist/zod/index.d.ts +8 -0
  26. package/dist/zod/index.js +210 -0
  27. package/dist/zod/jsonToZodSchemaMapper.d.ts +6 -0
  28. package/dist/zod/zodMinMaxFilter.d.ts +14 -0
  29. package/dist/zod/zodToTypeString.d.ts +9 -0
  30. package/package.json +102 -0
  31. package/src/api-wrapper/index.ts +492 -0
  32. package/src/array/index.ts +187 -0
  33. package/src/cache/index.ts +154 -0
  34. package/src/date/index.ts +54 -0
  35. package/src/gzip/index.ts +29 -0
  36. package/src/index.ts +12 -0
  37. package/src/jwt/index.ts +25 -0
  38. package/src/math/index.ts +11 -0
  39. package/src/math/linearRegressionTrend.ts +36 -0
  40. package/src/math/metricsSeriesColors.ts +208 -0
  41. package/src/p-queue/index.ts +690 -0
  42. package/src/promise/index.ts +73 -0
  43. package/src/sitemap/index.ts +195 -0
  44. package/src/state/index.ts +331 -0
  45. package/src/string/index.ts +86 -0
  46. package/src/typedswitch/index.ts +309 -0
  47. package/src/url/index.ts +5 -0
  48. package/src/zod/index.ts +29 -0
  49. package/src/zod/jsonToZodSchemaMapper.ts +44 -0
  50. package/src/zod/zodMinMaxFilter.ts +28 -0
  51. package/src/zod/zodToTypeString.ts +222 -0
@@ -0,0 +1,73 @@
1
+ export type BackgroundPromiseOptions = {
2
+ onError?: (error: unknown) => void
3
+ onSuccess?: () => void
4
+ scheduler?: (callback: () => void) => void
5
+ }
6
+
7
+ export type Success<T> = {
8
+ data: T
9
+ error: null
10
+ }
11
+
12
+ export type Failure<E> = {
13
+ data: null
14
+ error: E
15
+ }
16
+
17
+ export type Result<T, E = Error> = Success<T> | Failure<E>
18
+
19
+ export function isSuccess<T, E>(result: Result<T, E>): result is Success<T> {
20
+ return result.error === null
21
+ }
22
+
23
+ export function isFailure<T, E>(result: Result<T, E>): result is Failure<E> {
24
+ return result.error !== null
25
+ }
26
+
27
+ export function unwrapResult<T, E>(result: Result<T, E>): T {
28
+ if (isSuccess(result)) return result.data
29
+ throw result.error
30
+ }
31
+
32
+ export function backgroundPromise<T>(
33
+ promiseFactory: () => Promise<T>,
34
+ options: BackgroundPromiseOptions = {}
35
+ ): void {
36
+ const run = () => {
37
+ promiseFactory()
38
+ .then(() => options.onSuccess?.())
39
+ .catch((error: unknown) => options.onError?.(error))
40
+ }
41
+
42
+ if (options.scheduler) {
43
+ options.scheduler(run)
44
+ return
45
+ }
46
+
47
+ run()
48
+ }
49
+
50
+ export function backgroundPromiseSync<T>(
51
+ promiseFactory: () => Promise<T>,
52
+ options: BackgroundPromiseOptions = {}
53
+ ): void {
54
+ backgroundPromise(promiseFactory, {
55
+ ...options,
56
+ scheduler: options.scheduler ?? queueMicrotask
57
+ })
58
+ }
59
+
60
+ export function promiseTimeout(ms: number): Promise<void> {
61
+ return new Promise((resolve) => setTimeout(resolve, ms))
62
+ }
63
+
64
+ export async function tryCatch<T, E = Error>(
65
+ promise: Promise<T>
66
+ ): Promise<Result<T, E>> {
67
+ try {
68
+ const data = await promise
69
+ return { data, error: null }
70
+ } catch (error) {
71
+ return { data: null, error: error as E }
72
+ }
73
+ }
@@ -0,0 +1,195 @@
1
+ import PQueue from '../p-queue'
2
+
3
+ interface SitemapOptions {
4
+ filterIndexes?: string
5
+ concurrency?: number
6
+ maxDepth?: number
7
+ timeout?: number
8
+ }
9
+
10
+ interface ProcessedResult {
11
+ links: Set<string>
12
+ visited: Set<string>
13
+ }
14
+
15
+ /**
16
+ * Creates an AbortController with a timeout
17
+ */
18
+ const createTimeoutController = (timeout: number): AbortController => {
19
+ const controller = new AbortController()
20
+ setTimeout(() => controller.abort(), timeout)
21
+ return controller
22
+ }
23
+
24
+ /**
25
+ * Discovers sitemap URL(s) from a domain by checking common locations and robots.txt
26
+ */
27
+ const discoverSitemaps = async (domain: string): Promise<string[]> => {
28
+ const normalizedDomain = domain.replace(/\/$/, '')
29
+ const commonLocations = [
30
+ '/sitemap.xml',
31
+ '/sitemap_index.xml',
32
+ '/sitemap/',
33
+ '/sitemaps.xml',
34
+ '/sitemap/sitemap.xml'
35
+ ]
36
+
37
+ const sitemaps: Set<string> = new Set()
38
+ const requestOptions: RequestInit = {
39
+ headers: {
40
+ 'User-Agent': 'SitemapCrawler/1.0'
41
+ },
42
+ // biome-ignore lint/suspicious/noExplicitAny: AbortSignal types differ between Bun and React Native
43
+ signal: createTimeoutController(5000).signal as any
44
+ }
45
+
46
+ // Check robots.txt first
47
+ try {
48
+ const robotsTxt = await fetch(`${normalizedDomain}/robots.txt`, {
49
+ ...requestOptions
50
+ })
51
+
52
+ if (robotsTxt.ok) {
53
+ const text = await robotsTxt.text()
54
+ const sitemapMatches = text.match(/Sitemap: (.*)/gi)
55
+ if (sitemapMatches) {
56
+ sitemapMatches.forEach((match: string) => {
57
+ const url = match.replace('Sitemap:', '').trim()
58
+ sitemaps.add(url)
59
+ })
60
+ }
61
+
62
+ // If we found sitemaps in robots.txt, return them immediately
63
+ if (sitemaps.size > 0) {
64
+ return [...sitemaps]
65
+ }
66
+ }
67
+ } catch (error) {
68
+ console.warn(
69
+ 'Could not fetch robots.txt:',
70
+ error instanceof Error ? error.message : 'Unknown error'
71
+ )
72
+ }
73
+
74
+ // Check common locations one by one
75
+ for (const location of commonLocations) {
76
+ try {
77
+ const response = await fetch(`${normalizedDomain}${location}`, {
78
+ ...requestOptions,
79
+ method: 'HEAD'
80
+ })
81
+
82
+ if (response.ok) {
83
+ return [`${normalizedDomain}${location}`]
84
+ }
85
+ } catch (_error) {
86
+ // Ignore 404s and other errors
87
+ }
88
+ }
89
+
90
+ return []
91
+ }
92
+
93
+ /**
94
+ * Gets all URLs from a domain by discovering and crawling its sitemaps
95
+ * @param domain - The root domain (e.g., 'https://example.com')
96
+ * @param options - Configuration options for the crawler
97
+ * @returns Promise<string[]> - Array of unique URLs found in the sitemap
98
+ */
99
+ export const getDomainSitemap = async (
100
+ domain: string,
101
+ options: SitemapOptions = {}
102
+ ): Promise<string[]> => {
103
+ const {
104
+ filterIndexes,
105
+ concurrency = 10,
106
+ maxDepth = 5,
107
+ timeout = 10000
108
+ } = options
109
+
110
+ // Normalize domain
111
+ let normalizedDomain = domain.replace(/\/$/, '')
112
+ if (!normalizedDomain.startsWith('http')) {
113
+ normalizedDomain = `https://${normalizedDomain}`
114
+ }
115
+
116
+ // Discover sitemaps
117
+ const sitemapUrls = await discoverSitemaps(normalizedDomain)
118
+ if (sitemapUrls.length === 0) {
119
+ throw new Error('No sitemaps found for the domain')
120
+ }
121
+
122
+ const queue = new PQueue({ concurrency })
123
+ const result: ProcessedResult = {
124
+ links: new Set<string>(),
125
+ visited: new Set<string>()
126
+ }
127
+
128
+ const locRegex = /<loc>([^<]+)<\/loc>/g
129
+ const isXmlSitemap = (url: string) =>
130
+ url.endsWith('.xml') || url.toLowerCase().includes('sitemap')
131
+
132
+ /**
133
+ * Process a single sitemap URL and extract all links
134
+ */
135
+ const processSitemap = async (
136
+ sitemapUrl: string,
137
+ depth = 0
138
+ ): Promise<void> => {
139
+ if (depth > maxDepth || result.visited.has(sitemapUrl)) {
140
+ return
141
+ }
142
+
143
+ // console.log('Processing sitemap:', sitemapUrl)
144
+
145
+ result.visited.add(sitemapUrl)
146
+
147
+ try {
148
+ const controller = createTimeoutController(timeout)
149
+ const response = await fetch(sitemapUrl, {
150
+ headers: {
151
+ 'User-Agent': 'SitemapCrawler/1.0'
152
+ },
153
+ // biome-ignore lint/suspicious/noExplicitAny: AbortSignal types differ between Bun and React Native
154
+ signal: controller.signal as any
155
+ })
156
+
157
+ if (!response.ok)
158
+ throw new Error(`HTTP error! status: ${response.status}`)
159
+
160
+ const data = await response.text()
161
+ const matches = [...data.matchAll(locRegex)]
162
+ .map((match) => match[1]!.trim())
163
+ .filter((link) => {
164
+ if (filterIndexes && isXmlSitemap(link)) {
165
+ return link.includes(filterIndexes)
166
+ }
167
+ return true
168
+ })
169
+
170
+ for (const link of matches) {
171
+ if (isXmlSitemap(link)) {
172
+ // Queue nested sitemap for processing
173
+ queue.add(() => processSitemap(link, depth + 1))
174
+ } else {
175
+ result.links.add(link)
176
+ }
177
+ }
178
+ } catch (error) {
179
+ console.warn(
180
+ `Failed to process sitemap ${sitemapUrl}:`,
181
+ error instanceof Error ? error.message : 'Unknown error'
182
+ )
183
+ }
184
+ }
185
+
186
+ // Process all discovered sitemaps
187
+ await Promise.all(
188
+ sitemapUrls.map((sitemapUrl) => queue.add(() => processSitemap(sitemapUrl)))
189
+ )
190
+
191
+ // Wait for all queued tasks to complete
192
+ await queue.onIdle()
193
+
194
+ return [...result.links]
195
+ }
@@ -0,0 +1,331 @@
1
+ /**
2
+ * Simple State Machine - A lightweight state machine implementation
3
+ *
4
+ * This provides a generic state machine abstraction that can be used
5
+ * for various workflows and business processes.
6
+ */
7
+
8
+ /**
9
+ * Type for validation result
10
+ * - true: transition is valid
11
+ * - string: transition is invalid with reason
12
+ */
13
+ export type ValidationResult = true | string
14
+
15
+ /**
16
+ * Type for validation function
17
+ * @param context The context object containing data needed for validation
18
+ * @param fromState The state transitioning from
19
+ * @param toState The state transitioning to
20
+ * @param action When validating a specific action, the action key (for per-action rules)
21
+ */
22
+ export type ValidatorFn<
23
+ TState extends string,
24
+ TContext,
25
+ TAction extends string = string
26
+ > = (
27
+ context: TContext,
28
+ fromState: TState,
29
+ toState: TState,
30
+ action?: TAction
31
+ ) => Promise<ValidationResult> | ValidationResult
32
+
33
+ /**
34
+ * Type for action handler function
35
+ * @param context The context object containing data needed for validation
36
+ * @param data Additional data passed to the action
37
+ * @returns Result of executing the action
38
+ */
39
+ export type ActionHandlerFn<TContext, TData = unknown, TResult = unknown> = (
40
+ context: TContext,
41
+ data: TData
42
+ ) => Promise<TResult> | TResult
43
+
44
+ export type ActionDataMap<TAction extends string> = Record<TAction, unknown>
45
+ export type ActionResultMap<TAction extends string> = Record<TAction, unknown>
46
+ export type StateKey<TContext, TState extends string> = {
47
+ [K in keyof TContext]: TContext[K] extends TState ? K : never
48
+ }[keyof TContext]
49
+
50
+ export type ActionExecutionResult<TState extends string, TResult = unknown> =
51
+ | {
52
+ success: true
53
+ newState: TState
54
+ result?: TResult
55
+ }
56
+ | {
57
+ success: false
58
+ error: string
59
+ }
60
+
61
+ /**
62
+ * Interface for state machine configuration
63
+ */
64
+ export interface StateMachineConfig<
65
+ TState extends string,
66
+ TAction extends string,
67
+ TContext,
68
+ TActionDataMap extends ActionDataMap<TAction> = ActionDataMap<TAction>,
69
+ TActionResultMap extends ActionResultMap<TAction> = ActionResultMap<TAction>
70
+ > {
71
+ // Maps states to possible actions and their target states
72
+ stateActionMap: Record<TState, Partial<Record<TAction, TState>>>
73
+
74
+ // Maps states to allowed direct transitions
75
+ stateTransitions: Record<TState, readonly TState[]>
76
+
77
+ // Validation functions for specific states
78
+ validators?: Partial<Record<TState, ValidatorFn<TState, TContext, TAction>>>
79
+
80
+ // Global validator that runs for all transitions
81
+ globalValidator?: ValidatorFn<TState, TContext, TAction>
82
+
83
+ // Action handlers for each action
84
+ actions?: {
85
+ [K in TAction]?: ActionHandlerFn<
86
+ TContext,
87
+ TActionDataMap[K],
88
+ TActionResultMap[K]
89
+ >
90
+ }
91
+ }
92
+
93
+ export type ValidateTransitionOptions<TAction extends string> = {
94
+ action?: TAction
95
+ }
96
+
97
+ /**
98
+ * Creates a simple state machine with validation capabilities
99
+ */
100
+ export class SimpleStateMachine<
101
+ TState extends string,
102
+ TAction extends string,
103
+ TContext,
104
+ TActionDataMap extends ActionDataMap<TAction> = ActionDataMap<TAction>,
105
+ TActionResultMap extends ActionResultMap<TAction> = ActionResultMap<TAction>
106
+ > {
107
+ private config: StateMachineConfig<
108
+ TState,
109
+ TAction,
110
+ TContext,
111
+ TActionDataMap,
112
+ TActionResultMap
113
+ >
114
+ private item: TContext
115
+ private stateKey: StateKey<TContext, TState>
116
+
117
+ constructor(
118
+ config: StateMachineConfig<
119
+ TState,
120
+ TAction,
121
+ TContext,
122
+ TActionDataMap,
123
+ TActionResultMap
124
+ >,
125
+ item: TContext,
126
+ stateKey: StateKey<TContext, TState>
127
+ ) {
128
+ this.config = config
129
+ this.item = item
130
+ this.stateKey = stateKey
131
+ }
132
+
133
+ getCurrentStatus(): TState {
134
+ return this.item[this.stateKey] as TState
135
+ }
136
+
137
+ /**
138
+ * Get available actions for the current state with validation results
139
+ *
140
+ * @returns Map of actions to validation results
141
+ */
142
+ async getAvailableActions(): Promise<
143
+ Partial<Record<TAction, ValidationResult>>
144
+ > {
145
+ const actionsMap = this.config.stateActionMap[this.getCurrentStatus()] || {}
146
+ const result: Partial<Record<TAction, ValidationResult>> = {}
147
+
148
+ for (const [action, targetState] of Object.entries(actionsMap)) {
149
+ if (!targetState) continue
150
+
151
+ const validationResult = await this.validateTransition(
152
+ targetState as TState,
153
+ { action: action as TAction }
154
+ )
155
+
156
+ result[action as TAction] = validationResult
157
+ }
158
+
159
+ return result
160
+ }
161
+
162
+ /**
163
+ * Get all possible state transitions from the current state
164
+ *
165
+ * @returns Map of target states to validation results (true if any action to that state validates)
166
+ */
167
+ async getPossibleTransitions(): Promise<
168
+ Partial<Record<TState, ValidationResult>>
169
+ > {
170
+ const possibleTransitions =
171
+ this.config.stateTransitions[this.getCurrentStatus()] || []
172
+ const actionsMap = this.config.stateActionMap[this.getCurrentStatus()] || {}
173
+ const result: Partial<Record<TState, ValidationResult>> = {}
174
+
175
+ for (const targetState of possibleTransitions) {
176
+ const actionsToTarget = (
177
+ Object.entries(actionsMap) as [TAction, TState | undefined][]
178
+ ).filter(([, t]) => t === targetState)
179
+
180
+ if (actionsToTarget.length === 0) {
181
+ result[targetState] = await this.validateTransition(targetState)
182
+ continue
183
+ }
184
+
185
+ let lastError: ValidationResult = true
186
+ let anyOk = false
187
+ for (const [actionKey] of actionsToTarget) {
188
+ const r = await this.validateTransition(targetState, {
189
+ action: actionKey
190
+ })
191
+ if (r === true) {
192
+ anyOk = true
193
+ break
194
+ }
195
+ lastError = r
196
+ }
197
+ result[targetState] = anyOk ? true : lastError
198
+ }
199
+
200
+ return result
201
+ }
202
+
203
+ /**
204
+ * Executes an action with additional data
205
+ *
206
+ * @param action The action to execute
207
+ * @param data The data for the action
208
+ * @returns Promise with the execution result
209
+ */
210
+ async executeAction<A extends TAction>(
211
+ action: A,
212
+ data: TActionDataMap[A]
213
+ ): Promise<ActionExecutionResult<TState, TActionResultMap[A]>> {
214
+ const actionsMap = this.config.stateActionMap[this.getCurrentStatus()]
215
+ if (!actionsMap) {
216
+ return {
217
+ success: false,
218
+ error: `No actions defined for state '${this.getCurrentStatus()}'`
219
+ }
220
+ }
221
+
222
+ const targetState = actionsMap[action]
223
+ if (!targetState) {
224
+ return {
225
+ success: false,
226
+ error: `Action '${action}' not allowed in state '${this.getCurrentStatus()}'`
227
+ }
228
+ }
229
+
230
+ const validationResult = await this.validateTransition(targetState, {
231
+ action
232
+ })
233
+
234
+ if (validationResult === true) {
235
+ // If there's a custom action handler, call it with the provided data
236
+ if (this.config.actions?.[action]) {
237
+ try {
238
+ const result = await this.config.actions[action]!(this.item, data)
239
+ return {
240
+ success: true,
241
+ newState: targetState,
242
+ result
243
+ }
244
+ } catch (error) {
245
+ return {
246
+ success: false,
247
+ error: error instanceof Error ? error.message : String(error)
248
+ }
249
+ }
250
+ }
251
+
252
+ // Default behavior if no action handler
253
+ return {
254
+ success: true,
255
+ newState: targetState
256
+ }
257
+ }
258
+
259
+ return {
260
+ success: false,
261
+ error: validationResult
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Validates a transition between states
267
+ *
268
+ * @param toState The state transitioning to
269
+ * @param options When validating a specific action, pass `action` for per-action rules
270
+ */
271
+ async validateTransition(
272
+ toState: TState,
273
+ options?: ValidateTransitionOptions<TAction>
274
+ ): Promise<ValidationResult> {
275
+ const action = options?.action
276
+
277
+ // If states are the same, no transition is needed
278
+ if (this.getCurrentStatus() === toState) return true
279
+
280
+ // Check if transition is allowed in the state machine
281
+ const allowedTransitions =
282
+ this.config.stateTransitions[this.getCurrentStatus()] || []
283
+
284
+ if (!allowedTransitions.includes(toState)) {
285
+ return `Invalid transition from '${this.getCurrentStatus()}' to '${toState}'`
286
+ }
287
+
288
+ // Run global validator if provided
289
+ if (this.config.globalValidator) {
290
+ const globalResult = await this.config.globalValidator(
291
+ this.item,
292
+ this.getCurrentStatus(),
293
+ toState,
294
+ action
295
+ )
296
+ if (globalResult !== true) {
297
+ return globalResult
298
+ }
299
+ }
300
+
301
+ // Run state-specific validator if provided
302
+ const stateValidator = this.config.validators?.[toState]
303
+ if (stateValidator) {
304
+ return await stateValidator(
305
+ this.item,
306
+ this.getCurrentStatus(),
307
+ toState,
308
+ action
309
+ )
310
+ }
311
+
312
+ return true
313
+ }
314
+
315
+ /**
316
+ * Get all states in the state machine
317
+ */
318
+ getStates(): TState[] {
319
+ return Object.keys(this.config.stateTransitions) as TState[]
320
+ }
321
+
322
+ /**
323
+ * Get initial state of the state machine
324
+ * @returns The initial state
325
+ */
326
+ getInitialState(): TState | undefined {
327
+ // This implementation just returns the first state
328
+ // You might want to customize this based on your needs
329
+ return this.getStates()[0]
330
+ }
331
+ }
@@ -0,0 +1,86 @@
1
+ export type JsonValue =
2
+ | string
3
+ | number
4
+ | boolean
5
+ | null
6
+ | JsonObject
7
+ | JsonArray
8
+ export type JsonObject = { [key: string]: JsonValue | undefined }
9
+ export type JsonArray = Array<JsonValue | undefined>
10
+
11
+ export function extractNumber(
12
+ value: string | null | undefined
13
+ ): number | undefined {
14
+ if (!value?.trim()) return undefined
15
+
16
+ const cleanedValue = value.replace(/[^\d.,-]/g, '')
17
+ if (!cleanedValue) return undefined
18
+
19
+ const parsedNumber = Number.parseFloat(cleanedValue)
20
+ return Number.isNaN(parsedNumber) ? undefined : parsedNumber
21
+ }
22
+
23
+ export function generateId(input: JsonValue | undefined): string {
24
+ const data = normalizeIdInput(input)
25
+ let hash = 0
26
+
27
+ for (let index = 0; index < data.length; index++) {
28
+ const char = data.charCodeAt(index)
29
+ hash = (hash << 5) - hash + char
30
+ hash |= 0
31
+ }
32
+
33
+ return Math.abs(hash).toString(16).padStart(8, '0')
34
+ }
35
+
36
+ export function slugify(value: string): string {
37
+ const source =
38
+ 'àáâäæãåāăąçćčđďèéêëēėęěğǵḧîïíīįìłḿñńǹňôöòóœøōõṕŕřßśšşșťțûüùúūǘůűųẃẍÿýžźż·/_,:;'
39
+ const replacement =
40
+ 'aaaaaaaaaacccddeeeeeeeegghiiiiiilmnnnnooooooooprrsssssttuuuuuuuuuwxyyzzz------'
41
+ const specialCharacterPattern = new RegExp(source.split('').join('|'), 'g')
42
+
43
+ return value
44
+ .toString()
45
+ .toLowerCase()
46
+ .replace(/\s+/g, '-')
47
+ .replace(specialCharacterPattern, (character) =>
48
+ replacement.charAt(source.indexOf(character))
49
+ )
50
+ .replace(/&/g, '-and-')
51
+ .replace(/[^\w-]+/g, '')
52
+ .replace(/--+/g, '-')
53
+ .replace(/^-+/, '')
54
+ .replace(/-+$/, '')
55
+ }
56
+
57
+ function normalizeIdInput(input: JsonValue | undefined): string {
58
+ if (input === undefined) return 'undefined'
59
+ if (input === null) return 'null'
60
+ if (typeof input === 'string') {
61
+ return decodeURIComponent(input).split('&').sort().join('&')
62
+ }
63
+ if (typeof input === 'number' || typeof input === 'boolean') {
64
+ return input.toString()
65
+ }
66
+ return JSON.stringify(sortJsonValue(input))
67
+ }
68
+
69
+ function sortJsonValue(value: JsonValue | undefined): JsonValue | null {
70
+ if (value === undefined) return null
71
+
72
+ if (Array.isArray(value)) {
73
+ return value.map((item) => sortJsonValue(item))
74
+ }
75
+
76
+ if (typeof value === 'object' && value !== null) {
77
+ return Object.fromEntries(
78
+ Object.entries(value)
79
+ .filter(([, itemValue]) => itemValue !== undefined)
80
+ .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
81
+ .map(([key, itemValue]) => [key, sortJsonValue(itemValue)])
82
+ )
83
+ }
84
+
85
+ return value
86
+ }