@brickflow/http 0.0.15 → 0.0.16

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.
package/src/http.ts ADDED
@@ -0,0 +1,286 @@
1
+ export interface CreateHttpOptions {
2
+ arrayMode?: HttpArrayMode
3
+ baseURL: string
4
+ fetch?: typeof fetch
5
+ headers?: (() => HttpHeaders) | HttpHeaders
6
+ onResponse?: (response: HttpAnyResponse) => Promise<void> | void
7
+ timeout?: number
8
+ }
9
+
10
+ export type GetConfig<TKey extends HttpKey = HttpKey> = HttpConfigFull<TKey> & {
11
+ retry?: {
12
+ delay?: number
13
+ retries?: number
14
+ }
15
+ }
16
+
17
+ export type HttpAnyResponse = HttpResponse<HttpResponseData<HttpKey>, HttpKey, HttpConfigFull<HttpKey>>
18
+
19
+ export type HttpArrayMode = 'json' | 'repeat'
20
+
21
+ export type HttpClient = {
22
+ get: <T extends HttpKey, TConfig extends GetConfig<T> = GetConfig<T>>(
23
+ url: T,
24
+ config?: TConfig,
25
+ ) => Promise<HttpResponse<HttpResponseData<T>, T, TConfig>>
26
+ post: <T extends HttpKey, TConfig extends PostConfig<T> = PostConfig<T>>(
27
+ url: T,
28
+ data?: FormData | Record<string, unknown>,
29
+ config?: TConfig,
30
+ ) => Promise<HttpResponse<HttpResponseData<T>, T, TConfig>>
31
+ }
32
+
33
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
34
+ export interface HttpConfig<TKey extends HttpKey = HttpKey> {}
35
+ export interface HttpEndpoint {}
36
+ export type HttpErrorGuard<TData = unknown, TError extends TData = TData> = (payload: TData) => payload is TError
37
+
38
+ export type HttpKey = Extract<keyof HttpSchema, string>
39
+ export type HttpParam = Record<string, boolean | number | string | string[] | undefined>
40
+
41
+ export interface HttpResponse<
42
+ T = unknown,
43
+ TKey extends HttpKey = HttpKey,
44
+ TConfig extends HttpConfigFull<TKey> = HttpConfigFull<TKey>,
45
+ > {
46
+ config: HttpResponseConfig<TKey, TConfig>
47
+ data: T
48
+ status: number
49
+ }
50
+ export type HttpResponseConfig<
51
+ TKey extends HttpKey = HttpKey,
52
+ TConfig extends HttpConfig<TKey> = HttpConfig<TKey>,
53
+ > = Omit<TConfig, 'signal'> & { url: string }
54
+
55
+ export type HttpResponseData<TKey extends HttpKey = HttpKey> = HttpSchema[TKey]
56
+
57
+ export type PostConfig<TKey extends HttpKey = HttpKey> = HttpConfigFull<TKey>
58
+
59
+ type HttpConfigFull<TKey extends HttpKey = HttpKey> = HttpConfig<TKey> & {
60
+ params?: HttpParam
61
+ signal?: AbortSignal | AbortSignal[]
62
+ }
63
+
64
+ type HttpHeaders = Record<string, string | undefined>
65
+
66
+ type HttpSchema = keyof HttpEndpoint extends never ? Record<string, unknown> : HttpEndpoint
67
+
68
+ export function createHttp(options: CreateHttpOptions): HttpClient {
69
+ const arrayMode = options.arrayMode ?? 'json'
70
+ const clientFetch = options.fetch ?? fetch
71
+ const timeout = options.timeout ?? 80000
72
+
73
+ return {
74
+ get<T extends HttpKey, TConfig extends GetConfig<T> = GetConfig<T>>(url: T, config?: TConfig) {
75
+ const requestConfig = (config ?? {}) as TConfig
76
+ const requestUrl = `${joinUrl(options.baseURL, url)}${toQueryString(requestConfig.params, arrayMode)}`
77
+ const { delay = 300, retries = 3 } = requestConfig.retry ?? {}
78
+
79
+ const attemptRequest = async (attempt: number): Promise<HttpResponse<HttpResponseData<T>, T, TConfig>> => {
80
+ try {
81
+ const result = await clientFetch(requestUrl, {
82
+ credentials: 'include',
83
+ headers: resolveHeaders(options.headers),
84
+ method: 'GET',
85
+ signal: createSignal(requestConfig.signal, timeout),
86
+ })
87
+
88
+ const parsedResult = await result.json()
89
+ const response: HttpResponse<HttpResponseData<T>, T, TConfig> = {
90
+ config: createResponseConfig<T, TConfig>(joinUrl(options.baseURL, url), requestConfig),
91
+ data: parsedResult,
92
+ status: result.status,
93
+ }
94
+
95
+ if (!result.ok && isRetryableStatus(result.status) && attempt < retries) {
96
+ await wait(getRetryDelay(attempt, delay))
97
+ return attemptRequest(attempt + 1)
98
+ }
99
+
100
+ await options.onResponse?.(response as HttpAnyResponse)
101
+ return response
102
+ } catch (err) {
103
+ if (isAbortError(err) || attempt >= retries) {
104
+ throw err
105
+ }
106
+
107
+ await wait(getRetryDelay(attempt, delay))
108
+ return attemptRequest(attempt + 1)
109
+ }
110
+ }
111
+
112
+ return attemptRequest(0)
113
+ },
114
+ async post<T extends HttpKey, TConfig extends PostConfig<T> = PostConfig<T>>(
115
+ url: T,
116
+ data?: FormData | Record<string, unknown>,
117
+ config?: TConfig,
118
+ ) {
119
+ const requestConfig = (config ?? {}) as TConfig
120
+ const isForm = isFormData(data)
121
+ const requestUrl = `${joinUrl(options.baseURL, url)}${toQueryString(requestConfig.params, arrayMode)}`
122
+ const response = await clientFetch(requestUrl, {
123
+ body: isForm || data === undefined ? data : JSON.stringify(data),
124
+ credentials: 'include',
125
+ headers: resolveHeaders(
126
+ options.headers,
127
+ data !== undefined && !isForm ? { 'Content-Type': 'application/json' } : undefined,
128
+ ),
129
+ method: 'POST',
130
+ signal: createSignal(requestConfig.signal, timeout),
131
+ })
132
+
133
+ let parsedResult: Awaited<ReturnType<typeof response.json>> = {}
134
+ try {
135
+ parsedResult = await response.json()
136
+ } catch {
137
+ parsedResult = {}
138
+ }
139
+
140
+ const parsedResponse: HttpResponse<HttpResponseData<T>, T, TConfig> = {
141
+ config: createResponseConfig<T, TConfig>(joinUrl(options.baseURL, url), requestConfig),
142
+ data: parsedResult,
143
+ status: response.status,
144
+ }
145
+
146
+ await options.onResponse?.(parsedResponse as HttpAnyResponse)
147
+ return parsedResponse
148
+ },
149
+ }
150
+ }
151
+
152
+ function createAnySignal(signals: AbortSignal[]): AbortSignal {
153
+ if (typeof AbortSignal?.any === 'function') {
154
+ return AbortSignal.any(signals)
155
+ }
156
+
157
+ const controller = new AbortController()
158
+
159
+ signals.filter(Boolean).forEach((signal) => {
160
+ signal.addEventListener('abort', () => controller.abort(), { once: true })
161
+ })
162
+
163
+ return controller.signal
164
+ }
165
+
166
+ function createResponseConfig<TKey extends HttpKey, TConfig extends HttpConfigFull<TKey>>(
167
+ url: string,
168
+ config: TConfig,
169
+ ): HttpResponseConfig<TKey, TConfig> {
170
+ const responseConfig = { ...config } as Record<string, unknown>
171
+ delete responseConfig.signal
172
+
173
+ return {
174
+ ...responseConfig,
175
+ url,
176
+ } as HttpResponseConfig<TKey, TConfig>
177
+ }
178
+
179
+ function createSignal(signal: AbortSignal | AbortSignal[] | undefined, timeout: number): AbortSignal {
180
+ const timeoutSignal = createTimeoutSignal(timeout)
181
+
182
+ if (Array.isArray(signal)) {
183
+ return createAnySignal([...signal, timeoutSignal])
184
+ }
185
+
186
+ if (signal) {
187
+ return createAnySignal([signal, timeoutSignal])
188
+ }
189
+
190
+ return timeoutSignal
191
+ }
192
+
193
+ function createTimeoutSignal(ms: number): AbortSignal {
194
+ if (typeof AbortSignal?.timeout === 'function') {
195
+ return AbortSignal.timeout(ms)
196
+ }
197
+
198
+ const controller = new AbortController()
199
+ const timeoutId = setTimeout(() => controller.abort(), ms)
200
+
201
+ controller.signal.addEventListener('abort', () => clearTimeout(timeoutId), {
202
+ once: true,
203
+ })
204
+
205
+ return controller.signal
206
+ }
207
+
208
+ function getRetryDelay(attempt: number, delay: number): number {
209
+ return delay * 2 ** attempt
210
+ }
211
+
212
+ function isAbortError(err: unknown): boolean {
213
+ return err instanceof DOMException && err.name === 'AbortError'
214
+ }
215
+
216
+ function isFormData(value: FormData | Record<string, unknown> | undefined): value is FormData {
217
+ return typeof FormData !== 'undefined' && value instanceof FormData
218
+ }
219
+
220
+ function isRetryableStatus(status: number): boolean {
221
+ return status >= 500 || status === 429
222
+ }
223
+
224
+ function joinUrl(baseURL: string, url: string): string {
225
+ const normalizedBase = baseURL.endsWith('/') ? baseURL.slice(0, -1) : baseURL
226
+ const normalizedUrl = url.startsWith('/') ? url.slice(1) : url
227
+
228
+ return `${normalizedBase}/${normalizedUrl}`
229
+ }
230
+
231
+ function resolveHeaders(
232
+ value: CreateHttpOptions['headers'],
233
+ extraHeaders?: Record<string, string>,
234
+ ): Record<string, string> {
235
+ const headers = typeof value === 'function' ? value() : value
236
+
237
+ return Object.entries({
238
+ ...headers,
239
+ ...extraHeaders,
240
+ }).reduce<Record<string, string>>((acc, [key, headerValue]) => {
241
+ if (typeof headerValue === 'string') {
242
+ acc[key] = headerValue
243
+ }
244
+
245
+ return acc
246
+ }, {})
247
+ }
248
+
249
+ function serializeQueryValue(
250
+ urlParams: URLSearchParams,
251
+ key: string,
252
+ value: HttpParam[string],
253
+ arrayMode: HttpArrayMode,
254
+ ): void {
255
+ if (value === undefined) {
256
+ return
257
+ }
258
+
259
+ if (Array.isArray(value)) {
260
+ if (arrayMode === 'json') {
261
+ urlParams.append(key, JSON.stringify(value))
262
+ return
263
+ }
264
+
265
+ value.forEach((item) => {
266
+ urlParams.append(`${key}[]`, item)
267
+ })
268
+ return
269
+ }
270
+
271
+ urlParams.append(key, String(value))
272
+ }
273
+
274
+ function toQueryString(params?: HttpParam, arrayMode: HttpArrayMode = 'json'): string {
275
+ const urlParams = new URLSearchParams()
276
+
277
+ Object.entries(params ?? {}).forEach(([key, value]) => {
278
+ serializeQueryValue(urlParams, key, value, arrayMode)
279
+ })
280
+
281
+ return urlParams.size > 0 ? `?${urlParams}` : ''
282
+ }
283
+
284
+ function wait(ms: number): Promise<void> {
285
+ return new Promise((resolve) => setTimeout(resolve, ms))
286
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from './create-get'
2
+ export * from './http'
3
+ export * from './nuxt'
package/src/nuxt.ts ADDED
@@ -0,0 +1,355 @@
1
+ import { onScopeDispose, shallowReactive, shallowRef } from 'vue'
2
+
3
+ import { useLazyAsyncData, useState } from '#app'
4
+
5
+ import type { GetConfig, HttpClient, HttpErrorGuard, HttpKey, HttpParam, HttpResponseData } from './http'
6
+
7
+ import { createURL, hashData } from './utils'
8
+
9
+ const DAY = 1000 * 60 * 60 * 24
10
+ const DEFAULT_CHANNEL_NAME = 'http-tab-sync'
11
+ const DEFAULT_TTL = DAY * 7
12
+ export interface CreateUseHttpDependencies {
13
+ channelName?: string
14
+ getCache: () => null | UseHttpCache
15
+ getHttpClient: () => HttpClient
16
+ isDev?: () => boolean
17
+ isError?: HttpErrorGuard<unknown>
18
+ ttl?: number
19
+ }
20
+
21
+ export type HttpError = HttpErrorMap[keyof HttpErrorMap]
22
+ export interface HttpErrorMap {}
23
+ export type HttpSuccessData<TKey extends HttpKey = HttpKey> = Exclude<HttpResponseData<TKey>, HttpError>
24
+
25
+ export interface UseHttpCache {
26
+ deleteKeysWithPart: (part: string) => Promise<void>
27
+ get: <T>(key: string) => Promise<null | UseHttpCacheEntry<T>>
28
+ set: <T>(key: string, value: T, ttl: number) => Promise<void>
29
+ }
30
+
31
+ export interface UseHttpCacheEntry<T> {
32
+ hash: string
33
+ value: T
34
+ }
35
+
36
+ export interface UseHttpFn {
37
+ <T extends HttpKey, P extends HttpParam>(options: UseHttpOptions<T, P>): Promise<UseHttpResult<T, P>>
38
+ }
39
+
40
+ export interface UseHttpOptions<T extends HttpKey, P extends HttpParam> {
41
+ effect?: UseHttpEffect<T, P>
42
+ initParams?: P
43
+ isError?: (payload: HttpResponseData<T>) => boolean
44
+ lazy?: true
45
+ mapParams?: (params?: P) => P
46
+ server?: boolean
47
+ url: T
48
+ }
49
+
50
+ export interface UseHttpResult<T extends HttpKey, P extends HttpParam> {
51
+ data: HttpSuccessData<T> | null
52
+ error: null | UseHttpError<T>
53
+ fetch: UseHttpFetch<P>
54
+ hasFirstData: boolean
55
+ hasFreshData: boolean
56
+ pending: boolean
57
+ pendingCache: boolean
58
+ }
59
+
60
+ type BroadcastMessage = {
61
+ data: unknown
62
+ fullUrl: string
63
+ params: HttpParam
64
+ type: 'STATE_UPDATE'
65
+ }
66
+
67
+ type UseHttpEffect<T extends HttpKey, P extends HttpParam> = (
68
+ data: HttpResponseData<T>,
69
+ config: UseHttpEffectConfig<P>,
70
+ ) => void
71
+ type UseHttpEffectConfig<P extends HttpParam> = {
72
+ cached: boolean
73
+ params: P
74
+ }
75
+ type UseHttpError<T extends HttpKey> = Extract<HttpResponseData<T>, HttpError>
76
+
77
+ type UseHttpFetch<P extends HttpParam> = (params?: P, opt?: { signal: AbortSignal }) => Promise<void>
78
+
79
+ export function createUseHttp(dependencies: CreateUseHttpDependencies): UseHttpFn {
80
+ let channel: BroadcastChannel | null
81
+
82
+ const useHttp: UseHttpFn = async <T extends HttpKey, P extends HttpParam>(
83
+ options: UseHttpOptions<T, P>,
84
+ ): Promise<UseHttpResult<T, P>> => {
85
+ const mapParams = (params?: P): P => {
86
+ if (options.mapParams) {
87
+ return options.mapParams(params)
88
+ }
89
+
90
+ return params ?? ({} as P)
91
+ }
92
+ const effect = options.effect
93
+ const isError = createIsErrorGuard(options.isError, dependencies.isError)
94
+
95
+ const buildUrl = createURL
96
+ const initFullUrl = buildUrl(options.url, mapParams(options.initParams))
97
+ const httpClient = dependencies.getHttpClient()
98
+ const cache = import.meta.client ? (dependencies.getCache?.() ?? null) : null
99
+ let hasDataFromServer = false
100
+
101
+ const result = shallowReactive({
102
+ data: null as null | unknown,
103
+ error: null as null | unknown,
104
+ fetch: async (_params?: P, _opt?: { signal: AbortSignal }): Promise<void> => await undefined,
105
+ hasFirstData: false,
106
+ hasFreshData: false,
107
+ pending: true,
108
+ pendingCache: true,
109
+ })
110
+ const setError = (value: null | unknown): void => {
111
+ result.error = value
112
+ }
113
+ const setData = (value: null | unknown): void => {
114
+ result.data = value
115
+ }
116
+ const syncResult = (payload: HttpResponseData<T> | null | undefined): void => {
117
+ if (payload === null || payload === undefined) {
118
+ setData(null)
119
+ setError(null)
120
+ return
121
+ }
122
+
123
+ if (isError(payload)) {
124
+ setData(null)
125
+ setError(payload)
126
+ return
127
+ }
128
+
129
+ setData(payload)
130
+ setError(null)
131
+ }
132
+
133
+ const controller = new AbortController()
134
+ const serverData = useState<null | unknown>(`http-${initFullUrl}`, () => null)
135
+
136
+ if (options.server && import.meta.server) {
137
+ const paramsReactive = shallowRef(mapParams(options.initParams))
138
+ const ssr = await useLazyAsyncData(initFullUrl, async () => {
139
+ return await httpClient.get<T>(options.url, {
140
+ params: paramsReactive.value,
141
+ } as GetConfig<T>)
142
+ })
143
+
144
+ result.fetch = async (params?: P) => {
145
+ paramsReactive.value = mapParams(params)
146
+ await ssr.refresh()
147
+ }
148
+
149
+ serverData.value = ssr.data.value?.data ?? null
150
+ const serverPayload = serverData.value as HttpResponseData<T>
151
+ syncResult(serverPayload)
152
+
153
+ result.pending = false
154
+ result.pendingCache = false
155
+ result.hasFirstData = true
156
+ result.hasFreshData = true
157
+
158
+ if (serverData.value) {
159
+ effect?.(serverData.value as HttpResponseData<T>, {
160
+ cached: false,
161
+ params: mapParams(options.initParams),
162
+ })
163
+ }
164
+
165
+ if (result.data) {
166
+ hasDataFromServer = true
167
+ }
168
+ }
169
+
170
+ if (import.meta.client) {
171
+ channel = getChannel(dependencies.channelName ?? DEFAULT_CHANNEL_NAME, channel)
172
+ const fullUrlHistory: Record<string, true> = {}
173
+
174
+ function onMessage(event: MessageEvent<Partial<BroadcastMessage>>): void {
175
+ if (event.data.fullUrl && fullUrlHistory[event.data.fullUrl]) {
176
+ const eventPayload = event.data.data as HttpResponseData<T> | undefined
177
+
178
+ if (eventPayload) {
179
+ effect?.(eventPayload, {
180
+ cached: false,
181
+ params: event.data.params as P,
182
+ })
183
+ }
184
+
185
+ syncResult(eventPayload)
186
+
187
+ result.hasFirstData = true
188
+ result.hasFreshData = true
189
+ }
190
+ }
191
+
192
+ channel?.addEventListener('message', onMessage)
193
+
194
+ if (serverData.value) {
195
+ const clientServerPayload = serverData.value as HttpResponseData<T>
196
+ syncResult(clientServerPayload)
197
+ result.pending = false
198
+ result.pendingCache = false
199
+ result.hasFirstData = true
200
+ result.hasFreshData = true
201
+ }
202
+
203
+ const raceCondition: Record<string, number> = {}
204
+ const ttl = dependencies.ttl ?? DEFAULT_TTL
205
+
206
+ const runFetch = async (params?: P, fetchOpt?: { signal?: AbortSignal }): Promise<void> => {
207
+ const mappedParams = mapParams(params)
208
+ const fullUrl = buildUrl(options.url, mappedParams)
209
+ const fetchId = Date.now() + getRandom(0, 300)
210
+
211
+ if (raceCondition[fullUrl]) {
212
+ console.info('Race Condition affect', fullUrl)
213
+ return
214
+ }
215
+
216
+ raceCondition[fullUrl] = fetchId
217
+
218
+ try {
219
+ result.pending = true
220
+ result.pendingCache = true
221
+
222
+ const cachedFetch = cache && !dependencies.isDev?.() ? await cache.get<unknown>(fullUrl) : null
223
+
224
+ if (cachedFetch) {
225
+ const cachedPayload = cachedFetch.value as HttpResponseData<T>
226
+
227
+ effect?.(cachedPayload, {
228
+ cached: true,
229
+ params: mappedParams,
230
+ })
231
+
232
+ syncResult(cachedPayload)
233
+ result.hasFirstData = true
234
+
235
+ result.pendingCache = false
236
+ }
237
+
238
+ if (controller.signal.aborted || fetchOpt?.signal?.aborted) {
239
+ throw new DOMException('Aborted', 'AbortError')
240
+ }
241
+
242
+ const signalHttp = fetchOpt?.signal ? [controller.signal, fetchOpt.signal] : controller.signal
243
+ const response = await httpClient.get<T>(options.url, {
244
+ params: mappedParams,
245
+ signal: signalHttp,
246
+ } as GetConfig<T>)
247
+ const responsePayload = response.data as HttpResponseData<T>
248
+
249
+ effect?.(responsePayload, {
250
+ cached: false,
251
+ params: mappedParams,
252
+ })
253
+
254
+ if (isError(responsePayload)) {
255
+ syncResult(responsePayload)
256
+ } else {
257
+ syncResult(responsePayload)
258
+ const successData = responsePayload
259
+
260
+ fullUrlHistory[fullUrl] = true
261
+ channel?.postMessage({
262
+ data: normalizeBroadcastValue(successData),
263
+ fullUrl,
264
+ params: normalizeBroadcastValue(mappedParams),
265
+ type: 'STATE_UPDATE',
266
+ } satisfies BroadcastMessage)
267
+
268
+ if (cache) {
269
+ if (cachedFetch) {
270
+ const newHash = await hashData(responsePayload)
271
+ if (newHash !== cachedFetch.hash) {
272
+ await cache.deleteKeysWithPart(options.url)
273
+ }
274
+ }
275
+
276
+ if (response.status === 200) {
277
+ await cache.set(fullUrl, successData, ttl)
278
+ }
279
+ }
280
+ }
281
+
282
+ result.hasFirstData = true
283
+ result.hasFreshData = true
284
+ } catch (error) {
285
+ console.error(error)
286
+ } finally {
287
+ if (raceCondition[fullUrl] === fetchId) {
288
+ delete raceCondition[fullUrl]
289
+ }
290
+
291
+ result.pending = false
292
+ result.pendingCache = false
293
+ }
294
+ }
295
+
296
+ result.fetch = runFetch
297
+
298
+ if (!hasDataFromServer && options.lazy !== true) {
299
+ runFetch(options.initParams)
300
+ }
301
+
302
+ onScopeDispose(() => {
303
+ channel?.removeEventListener('message', onMessage)
304
+ controller.abort(`Http Abort -> onScopeDispose ${options.url}`)
305
+
306
+ if (serverData.value) {
307
+ serverData.value = null
308
+ }
309
+ })
310
+ }
311
+
312
+ return result as UseHttpResult<T, P>
313
+ }
314
+
315
+ return useHttp
316
+ }
317
+
318
+ function createIsErrorGuard<TData>(
319
+ localGuard?: (payload: TData) => boolean,
320
+ globalGuard?: HttpErrorGuard<unknown>,
321
+ ): (payload: TData) => boolean {
322
+ if (localGuard) {
323
+ return localGuard
324
+ }
325
+
326
+ if (globalGuard) {
327
+ return globalGuard as (payload: TData) => boolean
328
+ }
329
+
330
+ return (): boolean => false
331
+ }
332
+
333
+ function getChannel(name: string, currentChannel?: BroadcastChannel | null): BroadcastChannel | null {
334
+ if (currentChannel) {
335
+ return currentChannel
336
+ }
337
+
338
+ if (import.meta.server || typeof BroadcastChannel !== 'function') {
339
+ return null
340
+ }
341
+
342
+ return new BroadcastChannel(name)
343
+ }
344
+
345
+ function getRandom(min: number, max: number): number {
346
+ return Math.floor(Math.random() * (max - min + 1)) + min
347
+ }
348
+
349
+ function normalizeBroadcastValue<T>(payload: T): T {
350
+ try {
351
+ return structuredClone(payload)
352
+ } catch {
353
+ return JSON.parse(JSON.stringify(payload)) as T
354
+ }
355
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,50 @@
1
+ import type { HttpParam } from './http'
2
+
3
+ export function createURL(url: string, params?: HttpParam): string {
4
+ const urlParams = new URLSearchParams()
5
+
6
+ Object.entries(params ?? {}).forEach(([key, value]) => {
7
+ if (value === undefined) {
8
+ return
9
+ }
10
+
11
+ if (Array.isArray(value)) {
12
+ value.forEach((item) => {
13
+ urlParams.append(`${key}[]`, item)
14
+ })
15
+ return
16
+ }
17
+
18
+ urlParams.append(key, String(value))
19
+ })
20
+
21
+ const query = urlParams.size > 0 ? `?${urlParams}` : ''
22
+ return urlParams.size > 0 ? `${url}${query}` : url
23
+ }
24
+
25
+ export function fastDevHash(data: unknown): string {
26
+ const str = JSON.stringify(data)
27
+ let hash = 0
28
+
29
+ for (let i = 0; i < str.length; i++) {
30
+ const chr = str.charCodeAt(i)
31
+ hash = (hash << 5) - hash + chr
32
+ hash |= 0
33
+ }
34
+
35
+ return Math.abs(hash).toString(16)
36
+ }
37
+
38
+ export async function hashData(data: unknown): Promise<string> {
39
+ const subtle = globalThis.crypto?.subtle
40
+
41
+ if (subtle) {
42
+ const encoded = new TextEncoder().encode(JSON.stringify(data))
43
+ const buffer = await subtle.digest('SHA-1', encoded)
44
+ const array = Array.from(new Uint8Array(buffer))
45
+
46
+ return array.map((value) => value.toString(16).padStart(2, '0')).join('')
47
+ }
48
+
49
+ return fastDevHash(data)
50
+ }