2024-07-03 14:46:04 +02:00
|
|
|
import { SignalLike } from '$fresh/src/types.ts'
|
2024-06-13 12:22:49 +02:00
|
|
|
import { JsonValue } from '$std/json/common.ts'
|
2024-07-02 13:01:49 +02:00
|
|
|
import { decodeBase64 } from '@std/encoding/base64'
|
|
|
|
import { JsonStringifyStream } from '@std/json'
|
|
|
|
import { JsonParseStream } from '@std/json/json-parse-stream'
|
|
|
|
import { TextLineStream } from '@std/streams/text-line-stream'
|
2024-06-13 12:22:49 +02:00
|
|
|
|
|
|
|
export type JsonCompatible = JsonValue | { toJSON(): JsonValue } | unknown
|
|
|
|
|
|
|
|
export function respondApi<
|
|
|
|
Kind extends ApiPayload['kind'],
|
|
|
|
Payload extends JsonCompatible,
|
2024-06-13 12:43:29 +02:00
|
|
|
>(
|
|
|
|
kind: Kind,
|
|
|
|
payload?: Payload,
|
|
|
|
status?: number,
|
|
|
|
statusText?: string,
|
|
|
|
): Response {
|
2024-06-13 12:22:49 +02:00
|
|
|
if (kind === 'error') {
|
|
|
|
return Response.json({
|
|
|
|
kind: 'error',
|
|
|
|
error: String(payload ?? ''),
|
|
|
|
} as ApiPayload, {
|
2024-06-13 12:43:29 +02:00
|
|
|
status: status ?? 500,
|
|
|
|
statusText,
|
|
|
|
})
|
2024-06-13 12:22:49 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
return Response.json({
|
|
|
|
kind: 'success',
|
|
|
|
data: payload ?? null,
|
|
|
|
} as ApiPayload)
|
|
|
|
}
|
|
|
|
|
|
|
|
export async function requestApi<
|
|
|
|
Payload extends JsonCompatible | undefined,
|
|
|
|
ApiResponse extends JsonCompatible,
|
|
|
|
>(
|
|
|
|
route: string,
|
|
|
|
method: 'GET' | 'POST' | 'DELETE' | 'PATCH',
|
|
|
|
payload?: Payload | null,
|
|
|
|
): Promise<ApiResponse> {
|
2024-06-13 12:43:29 +02:00
|
|
|
const csrf = getCookie('_CSRF') ?? ''
|
2024-06-13 12:22:49 +02:00
|
|
|
|
|
|
|
const base = new URL('/api/', location.origin)
|
2024-06-13 12:43:29 +02:00
|
|
|
const endpoint = new URL(
|
|
|
|
route.startsWith('/') ? `.${route}` : route,
|
|
|
|
base.href,
|
|
|
|
)
|
2024-06-13 12:22:49 +02:00
|
|
|
|
|
|
|
const response = await fetch(endpoint, {
|
|
|
|
method,
|
|
|
|
headers: {
|
|
|
|
'Content-Type': 'application/json; charset=utf-8',
|
2024-06-13 12:43:29 +02:00
|
|
|
'X-CSRF-TOKEN': csrf,
|
2024-06-13 12:22:49 +02:00
|
|
|
},
|
|
|
|
body: payload ? JSON.stringify(payload) : null,
|
|
|
|
})
|
|
|
|
const apiPayload = await response.json() as ApiPayload<ApiResponse>
|
|
|
|
|
|
|
|
if (apiPayload.kind === 'error') {
|
|
|
|
throw new Error(`api request error while getting "${endpoint.href}"`, {
|
|
|
|
cause: apiPayload.error,
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
return apiPayload.data
|
|
|
|
}
|
|
|
|
|
|
|
|
export type ApiPayload<ApiResponse extends JsonCompatible = never> = {
|
|
|
|
kind: 'success'
|
|
|
|
data: ApiResponse
|
|
|
|
} | {
|
|
|
|
kind: 'error'
|
|
|
|
error: string
|
|
|
|
}
|
|
|
|
|
2024-07-02 13:01:49 +02:00
|
|
|
export async function respondApiStream<
|
|
|
|
Payload extends JsonCompatible,
|
|
|
|
>(
|
|
|
|
source:
|
|
|
|
| ReadableStream<Payload>
|
|
|
|
| Iterable<Payload>
|
|
|
|
| AsyncIterable<Payload>,
|
|
|
|
): Promise<Response> {
|
|
|
|
const stream = new TransformStream<
|
|
|
|
ApiPayload<Payload>,
|
|
|
|
ApiPayload<Payload>
|
|
|
|
>()
|
|
|
|
const writer = stream.writable.getWriter()
|
|
|
|
|
|
|
|
try {
|
|
|
|
await writer.ready
|
|
|
|
|
|
|
|
for await (const data of source) {
|
|
|
|
writer.write({ kind: 'success', data })
|
|
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
writer.write({ kind: 'error', error })
|
|
|
|
} finally {
|
|
|
|
writer.close()
|
|
|
|
}
|
|
|
|
|
|
|
|
const body = stream.readable
|
|
|
|
.pipeThrough(new JsonStringifyStream())
|
|
|
|
.pipeThrough(new TextEncoderStream())
|
|
|
|
|
|
|
|
return new Response(body)
|
|
|
|
}
|
|
|
|
|
|
|
|
export async function* requestApiStream<
|
|
|
|
Payload extends JsonCompatible | undefined,
|
|
|
|
ApiResponse extends JsonCompatible,
|
|
|
|
>(
|
|
|
|
route: string,
|
|
|
|
method: 'GET' | 'POST' | 'DELETE' | 'PATCH',
|
|
|
|
payload?: Payload | null,
|
|
|
|
): AsyncGenerator<ApiResponse, void, void> {
|
|
|
|
const csrf = getCookie('_CSRF') ?? ''
|
|
|
|
|
|
|
|
const base = new URL('/api/', location.origin)
|
|
|
|
const endpoint = new URL(
|
|
|
|
route.startsWith('/') ? `.${route}` : route,
|
|
|
|
base.href,
|
|
|
|
)
|
|
|
|
|
|
|
|
const response = await fetch(endpoint, {
|
|
|
|
method,
|
|
|
|
headers: {
|
|
|
|
'Content-Type': 'application/json; charset=utf-8',
|
|
|
|
'X-CSRF-TOKEN': csrf,
|
|
|
|
},
|
|
|
|
body: payload ? JSON.stringify(payload) : null,
|
|
|
|
})
|
|
|
|
|
|
|
|
const { body } = response
|
|
|
|
|
|
|
|
if (body === null) {
|
|
|
|
throw new TypeError(`api response stream is null`)
|
|
|
|
}
|
|
|
|
|
|
|
|
const stream = body
|
|
|
|
.pipeThrough(new TextDecoderStream()) // convert Uint8Array to string
|
|
|
|
.pipeThrough(new TextLineStream()) // transform into a stream where each chunk is divided by a newline
|
|
|
|
.pipeThrough(new JsonParseStream()) as unknown as ReadableStream<
|
|
|
|
ApiPayload<ApiResponse>
|
|
|
|
> // parse each chunk as JSON
|
|
|
|
|
|
|
|
for await (const payload of stream) {
|
|
|
|
if (payload.kind === 'error') {
|
|
|
|
throw new Error(
|
|
|
|
`api stream error while getting "${endpoint.href}"`,
|
|
|
|
{
|
|
|
|
cause: payload.error,
|
|
|
|
},
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
yield payload.data
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-06-13 12:22:49 +02:00
|
|
|
function getCookie(name: string): string | undefined {
|
2024-06-13 12:43:29 +02:00
|
|
|
const cookiesEntries = document.cookie.split(';').map((cookie) =>
|
|
|
|
cookie.trim().split('=')
|
|
|
|
)
|
|
|
|
const cookies = Object.fromEntries(cookiesEntries)
|
|
|
|
return cookies[name]
|
|
|
|
}
|
2024-07-01 13:33:27 +02:00
|
|
|
|
|
|
|
export function base64ToString(base64: string): string {
|
|
|
|
const bytes = decodeBase64(base64)
|
|
|
|
return new TextDecoder().decode(bytes)
|
2024-07-02 13:01:49 +02:00
|
|
|
}
|
2024-07-03 14:46:04 +02:00
|
|
|
|
|
|
|
export function unwrapSignalOrValue<T>(valueOrSignal: T | SignalLike<T>): T {
|
|
|
|
if (typeof valueOrSignal !== 'object') {
|
|
|
|
return valueOrSignal
|
|
|
|
}
|
|
|
|
|
|
|
|
if (valueOrSignal === null) {
|
|
|
|
return valueOrSignal
|
|
|
|
}
|
|
|
|
|
|
|
|
if (
|
|
|
|
'value' in valueOrSignal && 'peek' in valueOrSignal &&
|
|
|
|
'subscribe' in valueOrSignal
|
|
|
|
) {
|
|
|
|
return valueOrSignal.value
|
|
|
|
}
|
|
|
|
|
|
|
|
return valueOrSignal
|
|
|
|
}
|