Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/typiclient/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"dependencies": {
"@repo/typiserver": "*",
"superjson": "^2.2.2",
"zod": "^3.22.4"
"zod": "^3.25.6"
},
"devDependencies": {
"@repo/eslint-config": "*",
Expand Down
177 changes: 134 additions & 43 deletions packages/typiclient/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { deserialize } from "superjson";
import type { RouteHandlerResponse, TypiRouter } from "@repo/typiserver";
import type {
RouteHandlerErrorDataResponse,
RouteHandlerResponse,
TypiRouter,
} from "@repo/typiserver";
import {
type HttpMethod,
type HttpStatusCode,
Expand Down Expand Up @@ -44,11 +48,11 @@ export class TypiClient {
);
}
},
apply: (_, __, [input]) => {
apply: (_, __, [args]) => {
const method = path[path.length - 1].toUpperCase() as HttpMethod;
const urlWithoutMethod = this.path.slice(0, -1).join("/");
const url = `${this.baseUrl}/${urlWithoutMethod}`;
return this.executeRequest(url, method, input);
return this.executeRequest(url, method, args?.input, args?.options);
},
}) as any;
}
Expand Down Expand Up @@ -79,11 +83,11 @@ export class TypiClient {
.join("; ");
}

private async buildHeaders(input: any) {
private async buildHeaders(input: any, hasBody: boolean, hasFiles: boolean) {
const cookieHeader = this.buildCookieHeader(input?.cookies);

let headers = {
"Content-Type": "application/json",
...(hasBody && !hasFiles ? { "Content-Type": "application/json" } : {}),
...(input?.headers || {}),
...(cookieHeader ? { Cookie: cookieHeader } : {}),
};
Expand All @@ -109,29 +113,86 @@ export class TypiClient {
return headers;
}

private async buildRequestConfig(method: HttpMethod, input: any) {
const headers = await this.buildHeaders(input);
private hasFileData(obj: any): boolean {
if (obj instanceof File || obj instanceof Blob) {
return true;
}
if (obj && typeof obj === "object") {
return Object.values(obj).some((value) => this.hasFileData(value));
}
return false;
}

private buildFormData(data: any): FormData {
const formData = new FormData();

const appendToFormData = (obj: any, prefix = "") => {
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
const value = obj[key];
const formKey = prefix ? `${prefix}[${key}]` : key;

if (value instanceof File || value instanceof Blob) {
formData.append(formKey, value);
} else if (
value &&
typeof value === "object" &&
!(value instanceof Date)
) {
appendToFormData(value, formKey);
} else if (value !== null && value !== undefined) {
formData.append(formKey, String(value));
}
}
}
};

appendToFormData(data);
return formData;
}

private async buildRequestConfig(
method: HttpMethod,
input: any,
options?: ClientOptions
) {
const hasBody = method !== "get" && method !== "head" && input?.body;
const hasFiles = input?.body && this.hasFileData(input.body);
const headers = await this.buildHeaders(input, hasBody, hasFiles);

let body: string | FormData | undefined;

if (hasBody) {
if (hasFiles) body = this.buildFormData(input.body);
else body = JSON.stringify(input.body);
}

return {
credentials: this.options?.credentials,
credentials: options?.credentials || this.options?.credentials,
method: method,
headers: headers,
body:
method !== "get" && method !== "head" && input?.body
? JSON.stringify(input.body)
body: body,
signal: options?.timeout
? AbortSignal.timeout(options.timeout)
: this.options?.timeout
? AbortSignal.timeout(this.options.timeout)
: undefined,
signal: this.options?.timeout
? AbortSignal.timeout(this.options.timeout)
: undefined,
} as RequestInit;
}

private async executeInterceptors(
url: URL,
config: RequestInit,
response?: Response,
error?: any
) {
private async executeInterceptors({
url,
config,
response,
error,
options,
}: {
url: URL;
config: RequestInit;
response?: Response;
error?: any;
options?: ClientOptions;
}) {
// Handle request interceptors
if (!response && !error) {
for (const requestInterceptor of this.interceptors?.onRequest || []) {
Expand All @@ -152,7 +213,7 @@ export class TypiClient {
path: url.pathname,
config,
response,
retry: () => this.makeRequest(url, config),
retry: () => this.makeRequest(url, config, options),
});
if (isRouteHandlerResponse(result)) {
return { result };
Expand All @@ -167,7 +228,7 @@ export class TypiClient {
path: url.pathname,
config,
error,
retry: () => this.makeRequest(url, config),
retry: () => this.makeRequest(url, config, options),
});
if (isRouteHandlerResponse(result)) {
return { result };
Expand All @@ -178,53 +239,80 @@ export class TypiClient {
return {};
}

private async makeRequest(url: URL, config: RequestInit): Promise<any> {
private async makeRequest(
url: URL,
config: RequestInit,
options?: ClientOptions
): Promise<any> {
try {
const response = await fetch(url.toString(), config);

const interceptorResult = await this.executeInterceptors(
const interceptorResult = await this.executeInterceptors({
url,
config,
response
);
response,
options,
});
if (interceptorResult.result) {
return interceptorResult.result;
}

const data = deserialize(await response.json());
const status = getStatus(response.status as HttpStatusCode).key;

console[status === "OK" ? "log" : "error"](
`Request to ${url.toString()} returned status ${status}`,
data
);
console[status === "OK" ? "log" : "warn"]({
URL: url.toString(),
config: config,
status: status,
data: data,
});

if (
status !== "OK" &&
(options?.throwOnErrorStatus ?? this.options?.throwOnErrorStatus)
) {
throw new Error((data as RouteHandlerErrorDataResponse).error.message);
}

return {
status: status,
data: data as any,
response: response,
};
} catch (error) {
console.error(`Error making request to ${url.toString()}`, error);
const interceptorResult = await this.executeInterceptors(
console.error({
URL: url.toString(),
config: config,
error: error instanceof Error ? error : undefined,
});
const interceptorResult = await this.executeInterceptors({
url,
config,
undefined,
error
);
error,
options,
});
if (interceptorResult.result) {
return interceptorResult.result;
}
throw error;
}
}

private async executeRequest(path: string, method: HttpMethod, input: any) {
private async executeRequest(
path: string,
method: HttpMethod,
input: any,
options?: ClientOptions
) {
const url = this.buildUrl(path, input);
let config = await this.buildRequestConfig(method, input);
let config = await this.buildRequestConfig(method, input, options);

// Execute request interceptors
const interceptorResult = await this.executeInterceptors(url, config);
const interceptorResult = await this.executeInterceptors({
url,
config,
options,
});

if (interceptorResult.result) {
return interceptorResult.result;
Expand All @@ -234,11 +322,14 @@ export class TypiClient {
config = interceptorResult.config;
}

return this.makeRequest(url, config);
return this.makeRequest(url, config, options);
}
}

export function createTypiClient<T extends TypiRouter>({
export function createTypiClient<
TRouter extends TypiRouter,
TOptions extends ClientOptions,
>({
baseUrl,
baseHeaders,
interceptors,
Expand All @@ -247,15 +338,15 @@ export function createTypiClient<T extends TypiRouter>({
baseUrl: string;
baseHeaders?: BaseHeaders;
interceptors?: RequestInterceptors;
options?: ClientOptions;
}): TypiClientInstance<T> {
options?: TOptions;
}): TypiClientInstance<TRouter, TOptions> {
return new TypiClient(
baseUrl,
[],
baseHeaders,
interceptors,
options
) as TypiClientInstance<T>;
) as TypiClientInstance<TRouter, TOptions>;
}

const isRouteHandlerResponse = (
Expand Down
4 changes: 2 additions & 2 deletions packages/typiclient/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export * from "./client"
export * from "./types"
export * from "./client";
export * from "./types";
Loading
Loading