diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05ad166..eab7b04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,4 +22,20 @@ jobs: run: bun typecheck - name: Test - run: bun test \ No newline at end of file + run: bun run test + release: + name: release + needs: test + if: ${{ !contains(github.event.head_commit.message, 'skip ci') && !contains(github.event.head_commit.message, 'skip release') && github.event_name != 'pull_request' }} + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - uses: ./.github/setup + - run: bun run release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 45f4a52..6850b99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,4 +58,11 @@ ### ❤️ Contributors -- Bekacru \ No newline at end of file +- Bekacru + +## v.1.2.0 + +### 🚀 Release + +- typed routes. Now you can create typed routes with betterFetch. +- custom fetch implementation now supports node-fetch \ No newline at end of file diff --git a/README.md b/README.md index de5aac3..9d2ed8b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Better Fetch -A fetch wrapper for typescript that returns data and error object. Works on the browser, node (version 18+), workers, deno and bun. Some of the APIs are inspired by [ofetch](https://github.com/unjs/ofetch). +A fetch wrapper for typescript that returns data and error object, supports defined route schemas, plugins and more. Works on the browser, node (version 18+), workers, deno and bun. ## Installation @@ -8,12 +8,12 @@ A fetch wrapper for typescript that returns data and error object. Works on the pnpm install @better-tools/fetch ``` -## Usage +## Basic Usage ```typescript -import fetch from "@better-tools/fetch" +import betterFetch from "@better-tools/fetch" -const { data, error } = await fetch<{ +const { data, error } = await betterFetch<{ userId: number; id: number; title: string; @@ -47,12 +47,52 @@ const { data, error } = await $fetch<{ }>("/todos/1"); ``` +### ♯ Typed Fetch + +Better fetch allows you to define schema that will be used to infer request body, query parameters, response data and error types. + +```typescript +import { createFetch } from "@better-tools/fetch"; +import { T, FetchSchema } from "@better-tools/fetch/typed"; + +const routes = { + "/": { + output: T.Object({ + message: T.String(), + }), + }, + "/signin": { + input: T.Object({ + username: T.String(), + password: T.String(), + }), + output: T.Object({ + token: T.String(), + }), + }, + "/signup": { + input: T.Object({ + username: T.String(), + password: T.String(), + optional: T.Optional(T.String()), + }), + output: T.Object({ + message: T.String(), + }), + }, +} satisfies FetchSchema; + +const $fetch = createFetch() +``` + + You can also pass default response and error types. Which will be used if you don't pass the types in the fetch call. ```typescript import { createFetch } from "@better-tools/fetch"; +import { DefaultSchema } from "@better-tools/fetch/typed"; -const $fetch = createFetch<{ +const $fetch = createFetch("/todos/1"); + if (error) { + // handle the error + } + if (data) { + // handle the data + } + const { mutate, isPending } = useMutate("/todos") + await mutate({ + userId: 1, + id: 1, + title: "delectus aut autem", + completed: false + }) +} +``` + +Alternatively, you can directly import each individual hook. +```typescript +import { useFetch, useMutate } from "@better-tools/fetch/react"; + +function App() { + const { data, error } = useFetch<{ + userId: number; + id: number; + title: string; + completed: boolean; + }>("https://jsonplaceholder.typicode.com/todos/1"); + if (error) { + // handle the error + } + if (data) { + // handle the data + } +} +``` + + +### ♯ Plugins + +Plugins are functions that can be used to modify the request, response, error and other parts of the request lifecycle. + +Example: +```typescript +import { createFetch } from "@better-tools/fetch"; +import { csrfProtection } from "./plugins/csrfProtection" + +const $fetch = createFetch({ + baseUrl: "https://jsonplaceholder.typicode.com", + retry: 2, + plugins: [csrfProtection()] +}); +``` + + ### ♯ Parsing the response Better fetch will smartly parse JSON using JSON.parse and if it fails it will return the response as text. diff --git a/biome.json b/biome.json index 7c8add5..f0d4440 100644 --- a/biome.json +++ b/biome.json @@ -15,6 +15,9 @@ }, "suspicious": { "noExplicitAny": "off" + }, + "style": { + "noParameterAssign": "off" } } } diff --git a/bun.lockb b/bun.lockb index d7c079c..f92d194 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index de5a518..e475734 100644 --- a/package.json +++ b/package.json @@ -1,28 +1,53 @@ { "name": "@better-tools/fetch", - "version": "1.1.5", + "version": "1.2.0", "packageManager": "bun@1.1.4", "main": "./dist/index.cjs", "module": "./dist/index.js", "react-native": "./dist/index.js", "types": "./dist/index.d.ts", "devDependencies": { + "@happy-dom/global-registrator": "^14.7.1", + "@testing-library/react": "^15.0.3", "@types/bun": "^1.1.0", "@types/node": "^20.11.30", + "@types/react": "^18.2.79", "biome": "^0.3.3", + "react": "^18.2.0", "tsup": "^8.0.2", - "typescript": "^5.4.5" + "typescript": "^5.4.5", + "h3": "^1.11.1", + "@sinclair/typebox": "^0.32.27", + "@testing-library/dom": "^10.0.0", + "global-jsdom": "^24.0.0", + "jsdom": "^24.0.0", + "listhen": "^1.7.2", + "mocha": "^10.4.0", + "react-dom": "^18.2.0", + "vitest": "^1.5.0" }, "exports": { ".": { "import": "./dist/index.js", "require": "./dist/index.cjs" + }, + "./typed": { + "import": "./dist/typed.js", + "require": "./dist/typed.cjs", + "types": "./dist/typed.d.ts" + }, + "./react": { + "import": "./dist/react.js", + "require": "./dist/react.cjs", + "types": "./dist/react.d.ts" } }, "scripts": { "build": "tsup", + "test": "vitest run", + "test:watch": "vitest watch", "lint": "biome check .", - "release": "bun run build && npm publish --access public", + "release": "bun run build && npm publish", "lint:fix": "biome check . --apply", "typecheck": "tsc --noEmit" }, diff --git a/src/index.ts b/src/index.ts index e2802f7..8e6c945 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,10 +2,14 @@ import { Readable } from "stream"; import { FetchError } from "./error"; import { detectResponseType, + FetchEsque, + getFetch, isJSONParsable, isJSONSerializable, jsonParse, } from "./utils"; +import { FetchSchema, Static } from "./typed"; +import { TNever, TObject } from "@sinclair/typebox"; interface RequestContext { request: Request; @@ -17,7 +21,7 @@ interface ResponseContext { response: Response; } -export interface BetterFetchOptions extends Omit { +export type BetterFetchOptions = any> = { /** * a base url that will be prepended to the url */ @@ -81,43 +85,49 @@ export interface BetterFetchOptions extends Omit { */ duplex?: "full" | "half"; /** - * Query parameters + * HTTP method */ - query?: Record; + method?: PayloadMethod | NonPayloadMethod; + /** + * Custom fetch implementation + */ + customFetchImpl?: FetchEsque; + /** + * Plugins + */ + plugins?: Plugin[]; +} & Omit; + +/** + * A plugin that can be used to modify the url and options. + * All plugins will be called before the request is made. + */ +export interface Plugin { + (url: string, options?: FetchOption): Promise<{ + url: string; + options?: FetchOption; + }>; } // biome-ignore lint/suspicious/noEmptyInterface: export interface CreateFetchOption extends BetterFetchOptions {} -export type FetchOption = any> = ( - | { - body?: never; - } - | { - method: "POST"; - body: T; - } - | { - method: "GET"; - body?: never; - } - | { - method: - | "PUT" - | "DELETE" - | "PATCH" - | "HEAD" - | "OPTIONS" - | "CONNECT" - | "TRACE"; - body?: T; - } -) & - BetterFetchOptions; +export type PayloadMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; +export type NonPayloadMethod = "GET" | "HEAD" | "OPTIONS"; + +export type FetchOption< + T extends Record = any, + Q extends Record = any, +> = InferBody & InferQuery & BetterFetchOptions; + +type InferBody = T extends Record ? { body: T } : { body?: T }; +type InferQuery = Q extends Record + ? { query: Q } + : { query?: Q }; export type BetterFetchResponse< T, - E extends Record | unknown, + E extends Record | unknown = unknown, > = | { data: T; @@ -133,19 +143,27 @@ export type BetterFetchResponse< }; export const betterFetch: BetterFetch = async (url, options) => { + const fetch = getFetch(options?.customFetchImpl); const controller = new AbortController(); const signal = controller.signal; - const _url = new URL(`${options?.baseURL ?? ""}${url}`); + //run plugins first + // const fetcher = createFetch(options); + for (const plugin of options?.plugins || []) { + const pluginRes = await plugin(url.toString(), options); + url = pluginRes.url; + options = pluginRes.options; + } + + const _url = new URL(`${options?.baseURL ?? ""}${url.toString()}`); const headers = new Headers(options?.headers); const shouldStringifyBody = options?.body && - !(options.body instanceof FormData) && - !(options.body instanceof URLSearchParams) && isJSONSerializable(options.body) && (!headers.has("content-type") || - headers.get("content-type") === "application/json"); + headers.get("content-type") === "application/json") && + typeof options?.body !== "string"; if (shouldStringifyBody) { !headers.has("content-type") && @@ -162,11 +180,16 @@ export const betterFetch: BetterFetch = async (url, options) => { signal, ...options, body: shouldStringifyBody - ? JSON.stringify(options.body) + ? JSON.stringify(options?.body) : options?.body ? options.body : undefined, headers, + method: options?.method?.length + ? options.method + : options?.body + ? "POST" + : "GET", }; if ( @@ -195,7 +218,9 @@ export const betterFetch: BetterFetch = async (url, options) => { } await options?.onRequest?.(context); - const response = await fetch(context.request); + + const response = await fetch(_url.toString(), _options); + const responseContext: ResponseContext = { response, }; @@ -259,6 +284,7 @@ export const betterFetch: BetterFetch = async (url, options) => { }, }; } + return { data: null, error: { @@ -269,9 +295,13 @@ export const betterFetch: BetterFetch = async (url, options) => { }; }; -export const createFetch = ( +export const createFetch = < + Routes extends FetchSchema = any, + R = unknown, + E = unknown, +>( config?: CreateFetchOption, -): BetterFetch => { +): BetterFetch => { const $fetch: BetterFetch = async (url, options) => { return await betterFetch(url, { ...config, @@ -284,11 +314,37 @@ export const createFetch = ( betterFetch.native = fetch; -export interface BetterFetch { - (url: string | URL, options?: FetchOption): Promise< - BetterFetchResponse +export interface BetterFetch< + Routes extends FetchSchema = { + [key in string]: { + output: TNever; + }; + }, + BaseT = any, + BaseE = unknown, +> { + ( + url: K | URL | Omit, + ...options: Routes[K]["input"] extends TObject + ? [ + FetchOption< + Static, + Routes[K]["query"] extends TObject + ? Static + : any + >, + ] + : Routes[K]["query"] extends TObject + ? [FetchOption>] + : [FetchOption?] + ): Promise< + BetterFetchResponse< + Routes[K]["output"] extends TObject ? Static : T, + E + > >; native: typeof fetch; } + export type CreateFetch = typeof createFetch; export default betterFetch; diff --git a/src/react.ts b/src/react.ts new file mode 100644 index 0000000..f3e239f --- /dev/null +++ b/src/react.ts @@ -0,0 +1,186 @@ +import { useEffect, useState } from "react"; +import { + BetterFetchResponse, + createFetch, + FetchOption, + PayloadMethod, +} from "."; +import { isPayloadMethod } from "./utils"; +import { DefaultSchema, Static, T, TNever, TObject } from "./typed"; +import { FetchSchema } from "./typed"; + +const cache = (storage: Storage, disable?: boolean) => { + return { + set: (key: string, value: any) => { + if (disable) return; + storage.setItem(key, JSON.stringify(value)); + }, + get: (key: string) => { + if (disable) return null; + const value = storage.getItem(key); + return value ? (JSON.parse(value) as T) : null; + }, + }; +}; + +export type ReactFetchOptions = { + storage?: Storage; + /** + * Interval to refetch data in milliseconds + */ + refetchInterval?: number; + /** + * Refetch data on mount + */ + refetchOnMount?: boolean; + /** + * Refetch data on focus + */ + refetchOnFocus?: boolean; + /** + * Initial data + */ + initialData?: T; + /** + * Disable cache + * @default false + */ + disableCache?: boolean; +} & FetchOption; + +export type ReactMutateOptions = {} & FetchOption; + +const defaultOptions: ReactFetchOptions = { + refetchInterval: 0, + refetchOnMount: true, + refetchOnFocus: false, + disableCache: false, +}; + +export const createReactFetch = < + Routes extends FetchSchema = DefaultSchema, + R = unknown, + F = unknown, +>( + config?: ReactFetchOptions, +) => { + const betterFetch = createFetch(config); + const useFetch = < + R = unknown, + E = unknown, + K extends keyof Routes = keyof Routes, + >( + url: K | URL | Omit, + opts?: ReactFetchOptions, + ) => { + const options = { + ...defaultOptions, + ...config, + ...opts, + }; + const _cache = cache( + options?.storage || config?.storage || sessionStorage, + options.disableCache, + ); + const initial = { + data: options?.initialData ? options.initialData : null, + error: options?.initialData + ? null + : { status: 404, statusText: "Not Found" }, + } as any; + + const [res, setRes] = + useState< + Routes[K]["output"] extends TObject + ? BetterFetchResponse> + : BetterFetchResponse + >(initial); + const [isLoading, setIsLoading] = useState(false); + const fetchData = async () => { + setIsLoading(true); + const response = await betterFetch(url.toString(), options as any); + if (!response.error) { + _cache.set(url.toString(), response); + } + setIsLoading(false); + setRes(response as any); + }; + + useEffect(() => { + const onFocus = () => { + if (options?.refetchOnFocus) { + fetchData(); + } + }; + window.addEventListener("focus", onFocus); + return () => { + window.removeEventListener("focus", onFocus); + }; + }, []); + + useEffect(() => { + const refetchInterval = options?.refetchInterval + ? setInterval(fetchData, options?.refetchInterval) + : null; + const cached = _cache.get>(url.toString()); + if (cached && !options.refetchOnMount) { + setRes(cached as any); + } else { + fetchData(); + } + return () => { + setRes(initial); + if (refetchInterval) { + clearInterval(refetchInterval); + } + }; + }, []); + return { + data: res?.data as Routes[K]["output"] extends TObject + ? Static + : R, + error: isLoading ? null : res?.error, + isError: res?.error && !isLoading, + isLoading, + refetch: fetchData, + }; + }; + + const useMutate = ( + url: K | URL | Omit, + options?: ReactMutateOptions, + ) => { + if (options?.method && !isPayloadMethod(options?.method)) { + throw new Error("Method must be a payload method"); + } + + async function mutate( + ...args: T extends undefined + ? Routes[K]["input"] extends TObject + ? [Static>] + : [undefined?] + : [T] + ) { + const res = await betterFetch(url.toString(), { + ...options, + body: args[0], + method: (options?.method as PayloadMethod) || "POST", + } as any); + return res as BetterFetchResponse< + Static> + >; + } + return { + mutate, + }; + }; + + return { + betterFetch, + useFetch, + useMutate, + }; +}; + +export const { useFetch, betterFetch, useMutate } = + createReactFetch(); diff --git a/src/typed.ts b/src/typed.ts new file mode 100644 index 0000000..a0b117a --- /dev/null +++ b/src/typed.ts @@ -0,0 +1,22 @@ +import { TNever, TObject, Type } from "@sinclair/typebox"; + +export const T = Type; + +export * from "@sinclair/typebox"; + +export type DefaultSchema = { + [key: string]: { + input?: TNever; + output?: TNever; + query?: TNever; + }; +}; + +export type FetchSchema = Record< + string, + { + input?: TObject | TNever; + output?: TObject | TNever; + query?: TObject | TNever; + } +>; diff --git a/src/utils.ts b/src/utils.ts index 8f0ab71..118200a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -64,3 +64,32 @@ export function jsonParse(text: string) { return text; } } + +export interface FetchEsque { + (input: RequestInfo, init?: RequestInit): Promise; +} + +function isFunction(value: any): value is Function { + return typeof value === "function"; +} + +export function getFetch(customFetchImpl?: FetchEsque) { + if (customFetchImpl) { + return customFetchImpl; + } + if (typeof globalThis !== "undefined" && isFunction(globalThis.fetch)) { + return globalThis.fetch; + } + if (typeof window !== "undefined" && isFunction(window.fetch)) { + return window.fetch; + } + throw new Error("No fetch implementation found"); +} + +export function isPayloadMethod(method?: string) { + if (!method) { + return false; + } + const payloadMethod = ["POST", "PUT", "PATCH", "DELETE"]; + return payloadMethod.includes(method.toUpperCase()); +} diff --git a/test/index.test.ts b/test/index.test.ts index 34585b3..1ff0b4b 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,244 +1,199 @@ -import { describe, it, expect, beforeAll, afterAll } from "bun:test"; -import betterFetch from "../src"; -import { Server } from "bun"; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import betterFetch, { createFetch } from "../src"; +import { + createApp, + eventHandler, + readBody, + readRawBody, + toNodeListener, +} from "h3"; +import { listen, Listener } from "listhen"; +import { IncomingHttpHeaders } from "http"; +import { DefaultSchema } from "../src/typed"; describe("fetch", () => { const getURL = (path?: string) => - path ? `http://localhost:3000${path}` : "http://localhost:3000"; - let server: Server; - beforeAll(() => { - server = Bun.serve({ - port: 3000, - async fetch(request) { - const url = new URL(request.url); - if (url.pathname === "/text" && request.method === "GET") { - return new Response("OK"); - } - if (url.pathname === "/json") { - return new Response( - JSON.stringify({ - userId: 1, - id: 1, - title: "Hello", - completed: false, - }), - ); - } - if (url.pathname === "/blob" && request.method === "GET") { - const blob = new Blob(["binary"]); - return new Response(blob, { - headers: { - "Content-Type": "application/octet-stream", - }, - }); - } - if (url.pathname === "/error") { - return new Response("Error Message", { - status: 400, - }); - } - - if (url.pathname === "/error-json") { - return new Response( - JSON.stringify({ - key: "error", - }), - { - status: 400, - }, - ); - } - - if (url.pathname === "/echo" && request.method === "POST") { - const body = await request.text(); - return new Response(body, { - status: 200, - headers: request.headers, - }); - } - - if (url.pathname === "/form" && request.method === "POST") { - const body = await request.formData(); - return new Response( - JSON.stringify(Object.fromEntries(body.entries())), - ); - } - - if (url.pathname === "/url-params" && request.method === "POST") { - const body = await request.formData(); - return new Response( - JSON.stringify(Object.fromEntries(body.entries())), - ); - } - - if (url.pathname === "/403") { - return new Response("Forbidden", { - status: 403, - }); - } - - if (url.pathname === "/query" && request.method === "GET") { - const query = Object.fromEntries(url.searchParams.entries()); - return new Response(JSON.stringify(query)); - } - - return new Response(null, { - status: 404, - statusText: "Not Found", - }); - }, - }); + path ? `http://localhost:4000/${path}` : "http://localhost:4000"; + let listener: Listener; + beforeAll(async () => { + const app = createApp() + .use( + "/ok", + eventHandler(() => "ok"), + ) + .use( + "/params", + eventHandler((event) => + new URLSearchParams(event.node.req.url).toString(), + ), + ) + .use( + "/url", + eventHandler((event) => event.node.req.url), + ) + .use( + "/echo", + eventHandler(async (event) => { + return { + path: event.path, + body: + event.node.req.method === "POST" + ? await readRawBody(event) + : undefined, + headers: event.node.req.headers, + }; + }), + ) + .use( + "/post", + eventHandler(async (event) => { + return { + body: await readBody(event), + headers: event.node.req.headers, + }; + }), + ) + .use( + "/binary", + eventHandler((event) => { + event.node.res.setHeader("Content-Type", "application/octet-stream"); + return new Blob(["binary"]); + }), + ) + .use( + "/204", + eventHandler(() => null), + ); + listener = await listen(toNodeListener(app), { + port: 4000, + }); + }); + + const $echo = createFetch< + DefaultSchema, + { + body: any; + path: string; + headers: IncomingHttpHeaders; + } + >({ + baseURL: getURL(), }); afterAll(() => { - server.stop(); - }); - - it("text", async () => { - const { data } = await betterFetch(getURL("/text")); - expect(data).toBe("OK"); + listener.close().catch(console.error); }); - it("json", async () => { - const { data } = await betterFetch>(getURL("/json")); - expect(data).toEqual({ - userId: 1, - id: 1, - title: "Hello", - completed: false, - }); + it("ok", async () => { + expect((await betterFetch(getURL("ok"))).data).to.equal("ok"); }); - it("blob", async () => { - const { data } = await betterFetch(getURL("/blob")); - expect(data).toBeInstanceOf(Blob); + it("returns a blob for binary content-type", async () => { + const { data } = await betterFetch(getURL("binary")); + expect(data).to.has.property("size"); }); - it("error", async () => { - const { error } = await betterFetch(getURL("/error")); - expect(error).toEqual({ - status: 400, - statusText: "Bad Request", - message: "Error Message", + it("baseURL", async () => { + const { data } = await betterFetch("/x?foo=123", { + baseURL: getURL("url"), }); + expect(data).to.equal("/x?foo=123"); }); - it("error-json", async () => { - const { error } = await betterFetch( - getURL("/error-json"), - ); - expect(error).toEqual({ - status: 400, - statusText: "Bad Request", - key: "error", + it("stringifies posts body automatically", async () => { + const res = await betterFetch<{ body: { num: number } }>(getURL("post"), { + method: "POST", + body: { num: 42 }, }); - }); + expect(res.data?.body).toEqual({ num: 42 }); - it("does not stringify body when content type != application/json", async () => { - const message = "Hallo"; - const { data } = await betterFetch(getURL("/echo"), { + const res2 = await betterFetch(getURL("post"), { method: "POST", - body: message, - headers: { "Content-Type": "text/plain" }, + body: [{ num: 42 }, { num: 43 }], }); - expect(data).toBe(message); - }); + expect(res2.data?.body).toEqual([{ num: 42 }, { num: 43 }]); - it("baseURL", async () => { - const { data } = await betterFetch("/text", { baseURL: getURL() }); - expect(data).toBe("OK"); - }); + const headerFetches = [ + [["X-header", "1"]], + { "x-header": "1" }, + new Headers({ "x-header": "1" }), + ]; - it("stringifies posts body automatically", async () => { - const { data } = await betterFetch(getURL("/echo"), { - method: "POST", - body: { - test: "test", - num: 10, - }, - }); - expect(data).toEqual({ test: "test", num: 10 }); - const { data: data2 } = await betterFetch(getURL("/echo"), { - method: "POST", - body: [{ num: 42 }, { test: "test" }], - }); - expect(data2).toEqual([{ num: 42 }, { test: "test" }]); + for (const sentHeaders of headerFetches) { + const res2 = await betterFetch(getURL("post"), { + method: "POST", + body: { num: 42 }, + headers: sentHeaders as HeadersInit, + }); + expect(res2.data.headers).to.include({ "x-header": "1" }); + expect(res2.data.headers).to.include({ accept: "application/json" }); + } }); it("does not stringify body when content type != application/json", async () => { - const message = `{name: "test"}`; - const { data } = await betterFetch(getURL("/echo"), { + const message = '"Hallo von Pascal"'; + const { data } = await $echo("/echo", { method: "POST", body: message, headers: { "Content-Type": "text/plain" }, }); - expect(data).toBe(message); + expect(data?.body).toEqual(message); }); it("Handle Buffer body", async () => { - const message = "Test Message"; - const { data } = await betterFetch(getURL("/echo"), { + const message = "Hallo von Pascal"; + const { data } = await $echo("/echo", { method: "POST", - body: Buffer.from(message), + body: Buffer.from("Hallo von Pascal"), headers: { "Content-Type": "text/plain" }, }); - expect(data).toBe(message); - }); - - it("Bypass FormData body", async () => { - const formData = new FormData(); - formData.append("foo", "bar"); - const { data } = await betterFetch(getURL("/form"), { - method: "POST", - body: formData, - }); - expect(data).toEqual({ foo: "bar" }); + expect(data?.body).to.deep.eq(message); }); it("Bypass URLSearchParams body", async () => { const data = new URLSearchParams({ foo: "bar" }); - const { data: body } = await betterFetch(getURL("/url-params"), { + const { data: res } = await betterFetch(getURL("post"), { method: "POST", body: data, }); - expect(body).toMatchObject({ foo: "bar" }); + expect(res.body).toMatchObject({ foo: "bar" }); }); it("404", async () => { - const { error } = await betterFetch(getURL("/404")); - expect(error).toEqual({ + const { error, data } = await betterFetch< + {}, + { + statusCode: number; + stack: []; + statusMessage: string; + } + >(getURL("404")); + + expect(error).to.deep.eq({ + statusCode: 404, + statusMessage: "Cannot find any path matching /404.", + stack: [], status: 404, - statusText: "Not Found", + statusText: "Cannot find any path matching /404.", }); + expect(data).toBe(null); }); - it("403 with ignoreResponseError", async () => { - const { error } = await betterFetch(getURL("/403")); - expect(error).toEqual({ - status: 403, - statusText: "Forbidden", - message: "Forbidden", - }); + it("204 no content", async () => { + const { data } = await betterFetch(getURL("204")); + expect(data).toEqual({}); }); it("HEAD no content", async () => { - const { data, error } = await betterFetch(getURL("/text"), { + const { data } = await betterFetch(getURL("ok"), { method: "HEAD", }); expect(data).toEqual({}); - expect(error).toBeNull(); - }); - - it("appends query params", async () => { - const { data } = await betterFetch(getURL("/query"), { - query: { test: "test" }, - }); - expect(data).toEqual({ test: "test" }); }); it("should retry on error", async () => { let count = 0; - await betterFetch(getURL("/error"), { + await betterFetch(getURL("error"), { retry: 3, onError() { count++; @@ -246,4 +201,18 @@ describe("fetch", () => { }); expect(count).toBe(4); }); + + it("abort with retry", () => { + const controller = new AbortController(); + async function abortHandle() { + controller.abort(); + const response = await betterFetch("", { + baseURL: getURL("ok"), + retry: 3, + signal: controller.signal, + }); + console.log("response", response); + } + expect(abortHandle()).rejects.toThrow(/aborted/); + }); }); diff --git a/test/react.test.tsx b/test/react.test.tsx new file mode 100644 index 0000000..d932b1b --- /dev/null +++ b/test/react.test.tsx @@ -0,0 +1,182 @@ +import { describe, it, beforeAll, expect, afterAll } from "vitest"; + +import { createReactFetch, useFetch, useMutate } from "../src/react"; +import { render, fireEvent, screen, waitFor } from "@testing-library/react"; +import { createApp, eventHandler, readBody, toNodeListener } from "h3"; +import { listen, Listener } from "listhen"; +import { act } from "react-dom/test-utils"; +import { sleep } from "./utils"; + +describe("react", () => { + const getURL = (path?: string) => + path ? `http://localhost:3006${path}` : "http://localhost:3006"; + let listener: Listener; + beforeAll(async () => { + let count = 0; + const app = createApp() + .use( + "/text", + eventHandler(() => "OK") + ) + .use( + "/count", + eventHandler(() => { + return String(++count); + }) + ) + .use( + "/post", + eventHandler(async (req) => { + const body = await readBody(req); + return body; + }) + ) + .use( + "/get", + eventHandler(() => { + return { foo: "bar" }; + }) + ); + listener = await listen(toNodeListener(app), { + port: 3006, + }); + }); + + afterAll(() => { + listener.close().catch(console.error); + }); + + it("should work", async () => { + function Page() { + const { data } = useFetch(getURL("/text")); + return
data:{data}
; + } + const { getByText } = render(); + await waitFor(() => getByText("data:OK")); + }); + + it("should refetch", async () => { + function Page() { + const { data, refetch } = useFetch(getURL("/count")); + return ( +
+
data:{data}
+ +
+ ); + } + const { getByText } = render(); + await waitFor(() => getByText("data:1")); + fireEvent.click(getByText("refetch")); + await waitFor(() => getByText("data:2")); + }); + + it("should refetch in interval", async () => { + function Page() { + const { data, refetch } = useFetch(getURL("/count"), { + refetchInterval: 100, + }); + return ( +
+
data:{data}
+ +
+ ); + } + render(); + await sleep(200); + screen.getByText("data:4"); + }); + + // it("should refetch on focus", async () => { + // function Page() { + // const { data } = useFetch(getURL("/count"), { + // refetchOnFocus: true, + // }); + // return
data:{data}
; + // } + + // const { getByText } = render(); + // await waitFor(() => getByText("data:5")); + // act(() => { + // window.dispatchEvent(new Event("focus")); + // }); + // await waitFor(() => getByText("data:6")); + // }); + + // it("should cache", async () => { + // function Page() { + // const { data } = useFetch(getURL("/count"), { + // refetchOnMount: false, + // }); + // return
data:{data}
; + // } + // const { getByText } = render(); + // await waitFor(() => getByText("data:6")); + // }); + + it("shouldn't use cache", async () => { + function Page() { + const { data } = useFetch<{ + foo: string; + }>(getURL("/get"), { + refetchOnMount: false, + }); + return
data:{data?.foo}
; + } + const { getByText } = render(); + await waitFor(() => getByText("data:bar")); + }); + + it("should use initial data", async () => { + function Page() { + const { data } = useFetch(getURL("/count"), { + initialData: "0", + }); + return
data:{data}
; + } + const { getByText } = render(); + await waitFor(() => getByText("data:0")); + }); + + it("should mutate", async () => { + function Page() { + const mutate = useMutate<{ name: string }>(getURL("/post")); + + return ( +
+ +
+ ); + } + const { getByText } = render(); + fireEvent.click(getByText("invalidate")); + await sleep(100); + }); + + it("should work with create", async () => { + const { useFetch: useFetch2 } = createReactFetch({ + baseURL: getURL(), + }); + function Page() { + const { data } = useFetch2("/text"); + return
data:{data}
; + } + const { getByText } = render(); + await waitFor(() => getByText("data:OK")); + }); +}); diff --git a/test/typed.test.ts b/test/typed.test.ts new file mode 100644 index 0000000..96aa354 --- /dev/null +++ b/test/typed.test.ts @@ -0,0 +1,125 @@ +import { describe, expectTypeOf } from "vitest"; +import { DefaultSchema, FetchSchema, T } from "../src/typed"; +import { BetterFetchResponse, createFetch } from "../src"; +import { createReactFetch } from "../src/react"; + +const routes = { + "/": { + output: T.Object({ + message: T.String(), + }), + }, + "/signin": { + input: T.Object({ + username: T.String(), + password: T.String(), + }), + output: T.Object({ + token: T.String(), + }), + }, + "/signup": { + input: T.Object({ + username: T.String(), + password: T.String(), + optional: T.Optional(T.String()), + }), + output: T.Object({ + message: T.String(), + }), + }, + "/query": { + query: T.Object({ + term: T.String(), + }), + }, +} satisfies FetchSchema; + +describe("typed router", (it) => { + const $fetch = createFetch({ + baseURL: "https://example.com", + customFetchImpl: async (url, req) => { + return new Response(); + }, + }); + it("should not required body and return message", () => { + expectTypeOf($fetch("/")).toMatchTypeOf< + Promise> + >(); + }); + it("should required body and return token", () => { + //TODO: Check if we can fix excessively deep type error + expectTypeOf( + $fetch("/signin", { + //@ts-ignore + body: { username: "", password: "" }, + }), + ).toMatchTypeOf>>(); + }); + + it("should required body but should not required optional and return message", () => { + expectTypeOf( + $fetch("/signup", { + body: { + username: "", + password: "", + }, + }), + ).toMatchTypeOf>>(); + }); + + it("should require query param", () => { + expectTypeOf($fetch("/query", { query: { term: "" } })).toMatchTypeOf< + Promise> + >(); + }); + + it("should infer default response and error types", () => { + const f = createFetch({ + baseURL: "http://localhost:3000", + customFetchImpl: async (url, req) => { + return new Response(); + }, + }); + expectTypeOf(f("/")).toMatchTypeOf< + Promise< + BetterFetchResponse< + { + data: string; + }, + { + error: string; + } + > + > + >(); + }); +}); + +describe("react typed router", (it) => { + const { useFetch, useMutate } = createReactFetch(); + it("use fetch", () => { + function _() { + expectTypeOf(useFetch("/").data).toMatchTypeOf<{ + message: string; + } | null>(); + return null; + } + }); + + it("use mutate", () => { + function _() { + expectTypeOf( + useMutate("/signin").mutate({ username: "", password: "" }), + ).toMatchTypeOf>>(); + } + }); + + it("use mutate with optional property", () => { + function _() { + expectTypeOf( + useMutate("/signup").mutate({ username: "", password: "" }), + ).toMatchTypeOf>>(); + } + }); +}); diff --git a/test/utils.tsx b/test/utils.tsx new file mode 100644 index 0000000..a2d154e --- /dev/null +++ b/test/utils.tsx @@ -0,0 +1,29 @@ +import { act, fireEvent, render } from '@testing-library/react' +import { expect, vi } from 'vitest' + +export const mockConsoleForHydrationErrors = () => { + vi.spyOn(console, 'error').mockImplementation(() => { }) + return () => { + // It should not have any hydration warnings. + expect( + // @ts-expect-error + console.error.mock.calls.find(([err]) => { + return ( + err?.message?.includes( + 'Text content does not match server-rendered HTML.' + ) || + err?.message?.includes( + 'Hydration failed because the initial UI does not match what was rendered on the server.' + ) + ) + }) + ).toBeFalsy() + + // @ts-expect-error + console.error.mockRestore() + } +} +export function sleep(time: number) { + return new Promise(resolve => setTimeout(resolve, time)) +} +export const nextTick = () => act(() => sleep(1)) diff --git a/tsconfig.json b/tsconfig.json index ecc5476..2f101d6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,10 +4,13 @@ "module": "ESNext", "moduleResolution": "Node", "esModuleInterop": true, + "skipLibCheck": true, "outDir": "dist", "strict": true, "declaration": true, - "types": ["node"] + "types": ["node"], + "jsx": "react-jsx" }, - "include": ["src"] + "include": ["src", "test"], + "exclude": ["node_modules", "dist"] } diff --git a/tsup.config.ts b/tsup.config.ts index 8e7b4e0..042b845 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,12 +1,15 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: { - index: "./src/index.ts", - }, - splitting: false, - sourcemap: true, - format: ["esm", "cjs"], - dts: true, - clean: true, + entry: { + index: "./src/index.ts", + react: "./src/react.ts", + typed: "./src/typed.ts", + }, + splitting: false, + sourcemap: true, + format: ["esm", "cjs"], + dts: true, + clean: true, + external: ["react"], }); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..636da69 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "happy-dom", + }, +});