@brickflow/http 0.0.15 → 0.0.17

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