Replace entire codebase with homarr-labs/homarr
This commit is contained in:
33
packages/common/env.ts
Normal file
33
packages/common/env.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { randomBytes } from "crypto";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env";
|
||||
|
||||
const errorSuffix = `, please generate a 64 character secret in hex format or use the following: "${randomBytes(32).toString("hex")}"`;
|
||||
|
||||
export const env = createEnv({
|
||||
shared: {
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
||||
},
|
||||
server: {
|
||||
SECRET_ENCRYPTION_KEY: z
|
||||
.string({
|
||||
error: `SECRET_ENCRYPTION_KEY is required${errorSuffix}`,
|
||||
})
|
||||
.min(64, {
|
||||
message: `SECRET_ENCRYPTION_KEY has to be 64 characters${errorSuffix}`,
|
||||
})
|
||||
.max(64, {
|
||||
message: `SECRET_ENCRYPTION_KEY has to be 64 characters${errorSuffix}`,
|
||||
})
|
||||
.regex(/^[0-9a-fA-F]{64}$/, {
|
||||
message: `SECRET_ENCRYPTION_KEY must only contain hex characters${errorSuffix}`,
|
||||
}),
|
||||
NO_EXTERNAL_CONNECTION: createBooleanSchema(false),
|
||||
},
|
||||
runtimeEnv: {
|
||||
SECRET_ENCRYPTION_KEY: process.env.SECRET_ENCRYPTION_KEY,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
NO_EXTERNAL_CONNECTION: process.env.NO_EXTERNAL_CONNECTION,
|
||||
},
|
||||
});
|
||||
4
packages/common/eslint.config.js
Normal file
4
packages/common/eslint.config.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import baseConfig from "@homarr/eslint-config/base";
|
||||
|
||||
/** @type {import('typescript-eslint').Config} */
|
||||
export default [...baseConfig];
|
||||
1
packages/common/index.ts
Normal file
1
packages/common/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./src";
|
||||
47
packages/common/package.json
Normal file
47
packages/common/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@homarr/common",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./types": "./src/types.ts",
|
||||
"./server": "./src/server.ts",
|
||||
"./client": "./src/client.ts",
|
||||
"./env": "./env.ts"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@homarr/core": "workspace:^0.1.0",
|
||||
"@paralleldrive/cuid2": "^3.1.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"next": "16.1.1",
|
||||
"octokit": "^5.0.5",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"undici": "7.16.0",
|
||||
"zod": "^4.2.1",
|
||||
"zod-validation-error": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.39.2",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
15
packages/common/src/array.ts
Normal file
15
packages/common/src/array.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export const splitToNChunks = <T>(array: T[], chunks: number): T[][] => {
|
||||
const result: T[][] = [];
|
||||
for (let i = chunks; i > 0; i--) {
|
||||
result.push(array.splice(0, Math.ceil(array.length / i)));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const splitToChunksWithNItems = <T>(array: T[], itemCount: number): T[][] => {
|
||||
const result: T[][] = [];
|
||||
for (let i = 0; i < array.length; i += itemCount) {
|
||||
result.push(array.slice(i, i + itemCount));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
1
packages/common/src/client.ts
Normal file
1
packages/common/src/client.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./revalidate-path-action";
|
||||
10
packages/common/src/cookie.ts
Normal file
10
packages/common/src/cookie.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { SerializeOptions } from "cookie";
|
||||
import { parse, serialize } from "cookie";
|
||||
|
||||
export function parseCookies(cookieString: string) {
|
||||
return parse(cookieString);
|
||||
}
|
||||
|
||||
export function setClientCookie(name: string, value: string, options: SerializeOptions = {}) {
|
||||
document.cookie = serialize(name, value, options);
|
||||
}
|
||||
26
packages/common/src/date.ts
Normal file
26
packages/common/src/date.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import dayjs from "dayjs";
|
||||
import type { UnitTypeShort } from "dayjs";
|
||||
import isBetween from "dayjs/plugin/isBetween";
|
||||
|
||||
dayjs.extend(isBetween);
|
||||
|
||||
const validUnits = ["h", "d", "w", "M", "y"] as UnitTypeShort[];
|
||||
|
||||
export const isDateWithin = (date: Date, relativeDate: string): boolean => {
|
||||
if (relativeDate.length < 2) {
|
||||
throw new Error("Relative date must be at least 2 characters long");
|
||||
}
|
||||
|
||||
const amount = parseInt(relativeDate.slice(0, -1), 10);
|
||||
if (isNaN(amount) || amount <= 0) {
|
||||
throw new Error("Relative date must be a number greater than 0");
|
||||
}
|
||||
|
||||
const unit = relativeDate.slice(-1) as dayjs.UnitTypeShort;
|
||||
if (!validUnits.includes(unit)) {
|
||||
throw new Error("Invalid relative time unit");
|
||||
}
|
||||
|
||||
const startDate = dayjs().subtract(amount, unit);
|
||||
return dayjs(date).isBetween(startDate, dayjs(), null, "[]");
|
||||
};
|
||||
31
packages/common/src/encryption.ts
Normal file
31
packages/common/src/encryption.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
import { env } from "../env";
|
||||
|
||||
const algorithm = "aes-256-cbc"; //Using AES encryption
|
||||
|
||||
// We fallback to a key of 0s if the key was not provided because env validation was skipped
|
||||
// This should only be the case in CI
|
||||
const key = Buffer.from(env.SECRET_ENCRYPTION_KEY || "0".repeat(64), "hex");
|
||||
|
||||
export function encryptSecret(text: string): `${string}.${string}` {
|
||||
const initializationVector = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(algorithm, Buffer.from(key), initializationVector);
|
||||
let encrypted = cipher.update(text);
|
||||
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
||||
return `${encrypted.toString("hex")}.${initializationVector.toString("hex")}`;
|
||||
}
|
||||
|
||||
export function decryptSecret(value: `${string}.${string}`) {
|
||||
return decryptSecretWithKey(value, key);
|
||||
}
|
||||
|
||||
export function decryptSecretWithKey(value: `${string}.${string}`, key: Buffer) {
|
||||
const [data, dataIv] = value.split(".") as [string, string];
|
||||
const initializationVector = Buffer.from(dataIv, "hex");
|
||||
const encryptedText = Buffer.from(data, "hex");
|
||||
const decipher = crypto.createDecipheriv(algorithm, Buffer.from(key), initializationVector);
|
||||
let decrypted = decipher.update(encryptedText);
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
||||
return decrypted.toString();
|
||||
}
|
||||
24
packages/common/src/error.ts
Normal file
24
packages/common/src/error.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export const extractErrorMessage = (error: unknown) => {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
|
||||
return "Unknown error";
|
||||
};
|
||||
|
||||
export abstract class FlattenError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
private flattenResult: Record<string, unknown>,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public flatten(): Record<string, unknown> {
|
||||
return this.flattenResult;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
import type { AnyRequestError } from "../request-error";
|
||||
import { RequestError } from "../request-error";
|
||||
import { ResponseError } from "../response-error";
|
||||
import { matchErrorCode } from "./fetch-http-error-handler";
|
||||
import { HttpErrorHandler } from "./http-error-handler";
|
||||
|
||||
export class AxiosHttpErrorHandler extends HttpErrorHandler {
|
||||
constructor() {
|
||||
super("axios");
|
||||
}
|
||||
|
||||
handleRequestError(error: unknown): AnyRequestError | undefined {
|
||||
if (!(error instanceof AxiosError)) return undefined;
|
||||
if (error.code === undefined) return undefined;
|
||||
|
||||
this.logRequestError({
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
});
|
||||
|
||||
const requestErrorInput = matchErrorCode(error.code);
|
||||
if (!requestErrorInput) return undefined;
|
||||
|
||||
return new RequestError(requestErrorInput, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
handleResponseError(error: unknown): ResponseError | undefined {
|
||||
if (!(error instanceof AxiosError)) return undefined;
|
||||
if (error.response === undefined) return undefined;
|
||||
this.logResponseError({
|
||||
status: error.response.status,
|
||||
url: error.response.config.url,
|
||||
message: error.message,
|
||||
});
|
||||
|
||||
return new ResponseError({
|
||||
status: error.response.status,
|
||||
url: error.response.config.url ?? "?",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { objectEntries } from "../../../object";
|
||||
import type { Modify } from "../../../types";
|
||||
import type { AnyRequestError, AnyRequestErrorInput, RequestErrorCode, RequestErrorReason } from "../request-error";
|
||||
import { RequestError, requestErrorMap } from "../request-error";
|
||||
import type { ResponseError } from "../response-error";
|
||||
import { HttpErrorHandler } from "./http-error-handler";
|
||||
|
||||
export class FetchHttpErrorHandler extends HttpErrorHandler {
|
||||
constructor(private type = "undici") {
|
||||
super(type);
|
||||
}
|
||||
|
||||
handleRequestError(error: unknown): AnyRequestError | undefined {
|
||||
if (!isTypeErrorWithCode(error)) return undefined;
|
||||
|
||||
this.logRequestError({
|
||||
code: error.cause.code,
|
||||
});
|
||||
|
||||
const result = matchErrorCode(error.cause.code);
|
||||
if (!result) return undefined;
|
||||
|
||||
return new RequestError(result, { cause: error });
|
||||
}
|
||||
|
||||
/**
|
||||
* Response errors do not exist for fetch as it does not throw errors for non successful responses.
|
||||
*/
|
||||
handleResponseError(_: unknown): ResponseError | undefined {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
type TypeErrorWithCode = Modify<
|
||||
TypeError,
|
||||
{
|
||||
cause: Error & { code: string };
|
||||
}
|
||||
>;
|
||||
|
||||
const isTypeErrorWithCode = (error: unknown): error is TypeErrorWithCode => {
|
||||
return (
|
||||
error instanceof TypeError &&
|
||||
error.cause instanceof Error &&
|
||||
"code" in error.cause &&
|
||||
typeof error.cause.code === "string"
|
||||
);
|
||||
};
|
||||
|
||||
export const matchErrorCode = (code: string): AnyRequestErrorInput | undefined => {
|
||||
for (const [key, value] of objectEntries(requestErrorMap)) {
|
||||
const entries = Object.entries(value) as [string, string | string[]][];
|
||||
const found = entries.find(([_, entryCode]) =>
|
||||
typeof entryCode === "string" ? entryCode === code : entryCode.includes(code),
|
||||
);
|
||||
if (!found) continue;
|
||||
|
||||
return {
|
||||
type: key,
|
||||
reason: found[0] as RequestErrorReason<typeof key>,
|
||||
code: code as RequestErrorCode,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ILogger } from "@homarr/core/infrastructure/logs";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
import type { AnyRequestError } from "../request-error";
|
||||
import type { ResponseError } from "../response-error";
|
||||
|
||||
export abstract class HttpErrorHandler {
|
||||
protected logger: ILogger;
|
||||
|
||||
constructor(type: string) {
|
||||
this.logger = createLogger({ module: "httpErrorHandler", type });
|
||||
}
|
||||
|
||||
protected logRequestError<T extends { code: string }>(metadata: T) {
|
||||
this.logger.debug("Received request error", metadata);
|
||||
}
|
||||
|
||||
protected logResponseError<T extends { status: number; url: string | undefined }>(metadata: T) {
|
||||
this.logger.debug("Received response error", metadata);
|
||||
}
|
||||
|
||||
abstract handleRequestError(error: unknown): AnyRequestError | undefined;
|
||||
abstract handleResponseError(error: unknown): ResponseError | undefined;
|
||||
}
|
||||
6
packages/common/src/errors/http/handlers/index.ts
Normal file
6
packages/common/src/errors/http/handlers/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from "./http-error-handler";
|
||||
export * from "./fetch-http-error-handler";
|
||||
export * from "./ofetch-http-error-handler";
|
||||
export * from "./axios-http-error-handler";
|
||||
export * from "./tsdav-http-error-handler";
|
||||
export * from "./octokit-http-error-handler";
|
||||
@@ -0,0 +1,42 @@
|
||||
import { FetchError } from "node-fetch";
|
||||
|
||||
import { RequestError } from "../request-error";
|
||||
import type { AnyRequestError } from "../request-error";
|
||||
import type { ResponseError } from "../response-error";
|
||||
import { matchErrorCode } from "./fetch-http-error-handler";
|
||||
import { HttpErrorHandler } from "./http-error-handler";
|
||||
|
||||
/**
|
||||
* node-fetch was a defacto standard to use fetch in nodejs.
|
||||
*
|
||||
* It is for example used within the cross-fetch package which is used in tsdav.
|
||||
*/
|
||||
export class NodeFetchHttpErrorHandler extends HttpErrorHandler {
|
||||
constructor(private type = "node-fetch") {
|
||||
super(type);
|
||||
}
|
||||
|
||||
handleRequestError(error: unknown): AnyRequestError | undefined {
|
||||
if (!(error instanceof FetchError)) return undefined;
|
||||
if (error.code === undefined) return undefined;
|
||||
|
||||
this.logRequestError({
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
});
|
||||
|
||||
const requestErrorInput = matchErrorCode(error.code);
|
||||
if (!requestErrorInput) return undefined;
|
||||
|
||||
return new RequestError(requestErrorInput, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Response errors do not exist for fetch as it does not throw errors for non successful responses.
|
||||
*/
|
||||
handleResponseError(_: unknown): ResponseError | undefined {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { RequestError as OctokitRequestError } from "octokit";
|
||||
|
||||
import type { AnyRequestError } from "../request-error";
|
||||
import { ResponseError } from "../response-error";
|
||||
import { HttpErrorHandler } from "./http-error-handler";
|
||||
|
||||
export class OctokitHttpErrorHandler extends HttpErrorHandler {
|
||||
constructor() {
|
||||
super("octokit");
|
||||
}
|
||||
|
||||
/**
|
||||
* I wasn't able to get a request error triggered. Therefore we ignore them for now
|
||||
* and just forward them as unknown errors
|
||||
*/
|
||||
handleRequestError(_: unknown): AnyRequestError | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
handleResponseError(error: unknown): ResponseError | undefined {
|
||||
if (!(error instanceof OctokitRequestError)) return undefined;
|
||||
|
||||
this.logResponseError({
|
||||
status: error.status,
|
||||
url: error.response?.url,
|
||||
});
|
||||
|
||||
return new ResponseError({
|
||||
status: error.status,
|
||||
url: error.response?.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { FetchError } from "ofetch";
|
||||
|
||||
import type { AnyRequestError } from "../request-error";
|
||||
import { ResponseError } from "../response-error";
|
||||
import { FetchHttpErrorHandler } from "./fetch-http-error-handler";
|
||||
import { HttpErrorHandler } from "./http-error-handler";
|
||||
|
||||
/**
|
||||
* Ofetch is a wrapper around the native fetch API
|
||||
* which will always throw the FetchError (also for non successful responses).
|
||||
*
|
||||
* It is for example used within the ctrl packages like qbittorrent, deluge, transmission, etc.
|
||||
*/
|
||||
export class OFetchHttpErrorHandler extends HttpErrorHandler {
|
||||
constructor() {
|
||||
super("ofetch");
|
||||
}
|
||||
|
||||
handleRequestError(error: unknown): AnyRequestError | undefined {
|
||||
if (!(error instanceof FetchError)) return undefined;
|
||||
if (!(error.cause instanceof TypeError)) return undefined;
|
||||
|
||||
const result = new FetchHttpErrorHandler("ofetch").handleRequestError(error.cause);
|
||||
if (!result) return undefined;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
handleResponseError(error: unknown): ResponseError | undefined {
|
||||
if (!(error instanceof FetchError)) return undefined;
|
||||
if (error.response === undefined) return undefined;
|
||||
|
||||
this.logResponseError({
|
||||
status: error.response.status,
|
||||
url: error.response.url,
|
||||
});
|
||||
|
||||
return new ResponseError(error.response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { AnyRequestError } from "../request-error";
|
||||
import { ResponseError } from "../response-error";
|
||||
import { HttpErrorHandler } from "./http-error-handler";
|
||||
import { NodeFetchHttpErrorHandler } from "./node-fetch-http-error-handler";
|
||||
|
||||
export class TsdavHttpErrorHandler extends HttpErrorHandler {
|
||||
constructor() {
|
||||
super("tsdav");
|
||||
}
|
||||
|
||||
handleRequestError(error: unknown): AnyRequestError | undefined {
|
||||
return new NodeFetchHttpErrorHandler("tsdav").handleRequestError(error);
|
||||
}
|
||||
|
||||
handleResponseError(error: unknown): ResponseError | undefined {
|
||||
if (!(error instanceof Error)) return undefined;
|
||||
// Tsdav sadly does not throw a custom error and rather just uses "Error"
|
||||
// https://github.com/natelindev/tsdav/blob/bf33f04b1884694d685ee6f2b43fe9354b12d167/src/account.ts#L86
|
||||
if (error.message !== "Invalid credentials") return undefined;
|
||||
|
||||
this.logResponseError({
|
||||
status: 401,
|
||||
url: undefined,
|
||||
});
|
||||
|
||||
return new ResponseError({ status: 401, url: "?" });
|
||||
}
|
||||
}
|
||||
3
packages/common/src/errors/http/index.ts
Normal file
3
packages/common/src/errors/http/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./handlers";
|
||||
export * from "./request-error";
|
||||
export * from "./response-error";
|
||||
78
packages/common/src/errors/http/request-error.ts
Normal file
78
packages/common/src/errors/http/request-error.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
export type AnyRequestError = {
|
||||
[key in keyof RequestErrorMap]: RequestError<key>;
|
||||
}[keyof RequestErrorMap];
|
||||
|
||||
export type AnyRequestErrorInput = {
|
||||
[key in RequestErrorType]: RequestErrorInput<key>;
|
||||
}[RequestErrorType];
|
||||
|
||||
export interface RequestErrorInput<TType extends RequestErrorType> {
|
||||
type: TType;
|
||||
reason: RequestErrorReason<TType>;
|
||||
code: RequestErrorCode;
|
||||
}
|
||||
|
||||
export class RequestError<TType extends RequestErrorType> extends Error {
|
||||
public readonly type: TType;
|
||||
public readonly reason: RequestErrorReason<TType>;
|
||||
public readonly code: RequestErrorCode;
|
||||
|
||||
constructor(input: AnyRequestErrorInput, options: { cause?: Error }) {
|
||||
super("Request failed", options);
|
||||
this.name = RequestError.name;
|
||||
|
||||
this.type = input.type as TType;
|
||||
this.reason = input.reason as RequestErrorReason<TType>;
|
||||
this.code = input.code;
|
||||
}
|
||||
|
||||
get cause(): Error | undefined {
|
||||
return super.cause as Error | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export const requestErrorMap = {
|
||||
certificate: {
|
||||
expired: ["CERT_HAS_EXPIRED"],
|
||||
hostnameMismatch: ["ERR_TLS_CERT_ALTNAME_INVALID", "CERT_COMMON_NAME_INVALID"],
|
||||
notYetValid: ["CERT_NOT_YET_VALID"],
|
||||
untrusted: [
|
||||
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
||||
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
||||
"UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
|
||||
"SELF_SIGNED_CERT_IN_CHAIN",
|
||||
],
|
||||
},
|
||||
connection: {
|
||||
hostUnreachable: "EHOSTUNREACH",
|
||||
networkUnreachable: "ENETUNREACH",
|
||||
refused: "ECONNREFUSED",
|
||||
reset: "ECONNRESET",
|
||||
},
|
||||
dns: {
|
||||
notFound: "ENOTFOUND",
|
||||
timeout: "EAI_AGAIN",
|
||||
noAnswer: "ENODATA",
|
||||
},
|
||||
timeout: {
|
||||
aborted: "ECONNABORTED",
|
||||
timeout: "ETIMEDOUT",
|
||||
},
|
||||
} as const satisfies Record<string, Record<string, string | string[]>>;
|
||||
|
||||
type RequestErrorMap = typeof requestErrorMap;
|
||||
|
||||
export type RequestErrorType = keyof RequestErrorMap;
|
||||
|
||||
export type RequestErrorReason<TType extends RequestErrorType> = keyof RequestErrorMap[TType];
|
||||
export type AnyRequestErrorReason = {
|
||||
[key in keyof RequestErrorMap]: RequestErrorReason<key>;
|
||||
}[keyof RequestErrorMap];
|
||||
|
||||
type ExtractInnerValues<T> = {
|
||||
[K in keyof T]: T[K][keyof T[K]];
|
||||
}[keyof T];
|
||||
|
||||
type FlattenStringOrStringArray<T> = T extends (infer U)[] ? U : T;
|
||||
|
||||
export type RequestErrorCode = FlattenStringOrStringArray<ExtractInnerValues<typeof requestErrorMap>>;
|
||||
12
packages/common/src/errors/http/response-error.ts
Normal file
12
packages/common/src/errors/http/response-error.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export class ResponseError extends Error {
|
||||
public readonly statusCode: number;
|
||||
public readonly url?: string;
|
||||
|
||||
constructor(response: { status: number; url?: string }, options?: ErrorOptions) {
|
||||
super("Response did not indicate success", options);
|
||||
this.name = ResponseError.name;
|
||||
|
||||
this.statusCode = response.status;
|
||||
this.url = response.url;
|
||||
}
|
||||
}
|
||||
2
packages/common/src/errors/index.ts
Normal file
2
packages/common/src/errors/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./parse";
|
||||
export * from "./http";
|
||||
3
packages/common/src/errors/parse/handlers/index.ts
Normal file
3
packages/common/src/errors/parse/handlers/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./parse-error-handler";
|
||||
export * from "./zod-parse-error-handler";
|
||||
export * from "./json-parse-error-handler";
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ParseError } from "../parse-error";
|
||||
import { ParseErrorHandler } from "./parse-error-handler";
|
||||
|
||||
export class JsonParseErrorHandler extends ParseErrorHandler {
|
||||
constructor() {
|
||||
super("json");
|
||||
}
|
||||
|
||||
handleParseError(error: unknown): ParseError | undefined {
|
||||
if (!(error instanceof SyntaxError)) return undefined;
|
||||
|
||||
this.logParseError({
|
||||
message: error.message,
|
||||
});
|
||||
|
||||
return new ParseError("Failed to parse json", { cause: error });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ILogger } from "@homarr/core/infrastructure/logs";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
import type { ParseError } from "../parse-error";
|
||||
|
||||
export abstract class ParseErrorHandler {
|
||||
protected logger: ILogger;
|
||||
constructor(type: string) {
|
||||
this.logger = createLogger({ module: "parseErrorHandler", type });
|
||||
}
|
||||
|
||||
protected logParseError(metadata?: Record<string, unknown>) {
|
||||
this.logger.debug("Received parse error", metadata);
|
||||
}
|
||||
|
||||
abstract handleParseError(error: unknown): ParseError | undefined;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { ZodError } from "zod/v4";
|
||||
|
||||
import { ParseError } from "../parse-error";
|
||||
import { ParseErrorHandler } from "./parse-error-handler";
|
||||
|
||||
export class ZodParseErrorHandler extends ParseErrorHandler {
|
||||
constructor() {
|
||||
super("zod");
|
||||
}
|
||||
|
||||
handleParseError(error: unknown): ParseError | undefined {
|
||||
if (!(error instanceof ZodError)) return undefined;
|
||||
|
||||
// TODO: migrate to zod v4 prettfyError once it's released
|
||||
// https://v4.zod.dev/v4#error-pretty-printing
|
||||
const message = fromError(error, {
|
||||
issueSeparator: "\n",
|
||||
prefix: null,
|
||||
}).toString();
|
||||
|
||||
this.logParseError();
|
||||
|
||||
return new ParseError(message, { cause: error });
|
||||
}
|
||||
}
|
||||
2
packages/common/src/errors/parse/index.ts
Normal file
2
packages/common/src/errors/parse/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./handlers";
|
||||
export * from "./parse-error";
|
||||
10
packages/common/src/errors/parse/parse-error.ts
Normal file
10
packages/common/src/errors/parse/parse-error.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export class ParseError extends Error {
|
||||
constructor(message: string, options?: { cause: Error }) {
|
||||
super(`Failed to parse data:\n${message}`, options);
|
||||
this.name = ParseError.name;
|
||||
}
|
||||
|
||||
get cause(): Error {
|
||||
return super.cause as Error;
|
||||
}
|
||||
}
|
||||
3
packages/common/src/function.ts
Normal file
3
packages/common/src/function.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const isFunction = (value: unknown): value is (...args: unknown[]) => unknown => {
|
||||
return typeof value === "function";
|
||||
};
|
||||
44
packages/common/src/hooks.ts
Normal file
44
packages/common/src/hooks.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const calculateTimeAgo = (timestamp: Date) => {
|
||||
return dayjs().to(timestamp);
|
||||
};
|
||||
|
||||
export const useTimeAgo = (timestamp: Date, updateFrequency = 1000) => {
|
||||
const [timeAgo, setTimeAgo] = useState(() => calculateTimeAgo(timestamp));
|
||||
|
||||
useEffect(() => {
|
||||
setTimeAgo(calculateTimeAgo(timestamp));
|
||||
}, [timestamp]);
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = setInterval(() => setTimeAgo(calculateTimeAgo(timestamp)), updateFrequency);
|
||||
|
||||
return () => clearInterval(intervalId); // clear interval on hook unmount
|
||||
}, [timestamp, updateFrequency]);
|
||||
|
||||
return timeAgo;
|
||||
};
|
||||
|
||||
export const useIntegrationConnected = (updatedAt: Date, { timeout = 30000 }) => {
|
||||
const [connected, setConnected] = useState(Math.abs(dayjs(updatedAt).diff()) < timeout);
|
||||
|
||||
useEffect(() => {
|
||||
setConnected(Math.abs(dayjs(updatedAt).diff()) < timeout);
|
||||
|
||||
const delayUntilTimeout = timeout - Math.abs(dayjs(updatedAt).diff());
|
||||
const timeoutRef = setTimeout(() => {
|
||||
setConnected(false);
|
||||
}, delayUntilTimeout);
|
||||
|
||||
return () => clearTimeout(timeoutRef);
|
||||
}, [updatedAt, timeout]);
|
||||
|
||||
return connected;
|
||||
};
|
||||
1
packages/common/src/id.ts
Normal file
1
packages/common/src/id.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createId } from "@paralleldrive/cuid2";
|
||||
14
packages/common/src/index.ts
Normal file
14
packages/common/src/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export * from "./object";
|
||||
export * from "./string";
|
||||
export * from "./cookie";
|
||||
export * from "./array";
|
||||
export * from "./date";
|
||||
export * from "./stopwatch";
|
||||
export * from "./hooks";
|
||||
export * from "./id";
|
||||
export * from "./url";
|
||||
export * from "./number";
|
||||
export * from "./error";
|
||||
export * from "./theme";
|
||||
export * from "./function";
|
||||
export * from "./id";
|
||||
54
packages/common/src/number.ts
Normal file
54
packages/common/src/number.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
const ranges = [
|
||||
{ divider: 1e18, suffix: "E" },
|
||||
{ divider: 1e15, suffix: "P" },
|
||||
{ divider: 1e12, suffix: "T" },
|
||||
{ divider: 1e9, suffix: "G" },
|
||||
{ divider: 1e6, suffix: "M" },
|
||||
{ divider: 1e3, suffix: "k" },
|
||||
];
|
||||
|
||||
export const formatNumber = (value: number, decimalPlaces: number) => {
|
||||
for (const range of ranges) {
|
||||
if (value < range.divider) continue;
|
||||
|
||||
return (value / range.divider).toFixed(decimalPlaces) + range.suffix;
|
||||
}
|
||||
return value.toFixed(decimalPlaces);
|
||||
};
|
||||
|
||||
export const randomInt = (min: number, max: number) => {
|
||||
return Math.floor(Math.random() * (max - min + 1) + min);
|
||||
};
|
||||
|
||||
/**
|
||||
* Number of bytes to si format. (Division by 1024)
|
||||
* Does not accept floats, size in bytes should be an integer.
|
||||
* Will return "NaI" and logs a warning if a float is passed.
|
||||
* Concat as parameters so it is not added if the returned value is "NaI" or "∞".
|
||||
* Returns "∞" if the size is too large to be represented in the current format.
|
||||
*/
|
||||
export const humanFileSize = (size: number, concat = ""): string => {
|
||||
//64bit limit for Number stops at EiB
|
||||
const siRanges = ["B", "kiB", "MiB", "GiB", "TiB", "PiB", "EiB"];
|
||||
if (!Number.isInteger(size)) {
|
||||
console.warn(
|
||||
"Invalid use of the humanFileSize function with a float, please report this and what integration this is impacting.",
|
||||
);
|
||||
//Not an Integer
|
||||
return "NaI";
|
||||
}
|
||||
let count = 0;
|
||||
while (count < siRanges.length) {
|
||||
const tempSize = size / Math.pow(1024, count);
|
||||
if (tempSize < 1024) {
|
||||
return tempSize.toFixed(Math.min(count, 1)) + siRanges[count] + concat;
|
||||
}
|
||||
count++;
|
||||
}
|
||||
return "∞";
|
||||
};
|
||||
|
||||
const IMPERIAL_MULTIPLIER = 1.609344;
|
||||
|
||||
export const metricToImperial = (metricValue: number) => metricValue / IMPERIAL_MULTIPLIER;
|
||||
export const imperialToMetric = (imperialValue: number) => imperialValue * IMPERIAL_MULTIPLIER;
|
||||
15
packages/common/src/object.ts
Normal file
15
packages/common/src/object.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { hashKey } from "@tanstack/query-core";
|
||||
|
||||
export function objectKeys<O extends object>(obj: O): (keyof O)[] {
|
||||
return Object.keys(obj) as (keyof O)[];
|
||||
}
|
||||
|
||||
type Entries<T> = {
|
||||
[K in keyof T]: [K, T[K]];
|
||||
}[keyof T][];
|
||||
|
||||
export const objectEntries = <T extends object>(obj: T) => Object.entries(obj) as Entries<T>;
|
||||
|
||||
export const hashObjectBase64 = (obj: object) => {
|
||||
return Buffer.from(hashKey([obj])).toString("base64");
|
||||
};
|
||||
15
packages/common/src/request.ts
Normal file
15
packages/common/src/request.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { userAgent as userAgentNextServer } from "next/server";
|
||||
|
||||
import type { Modify } from "./types";
|
||||
|
||||
export const userAgent = (headers: Headers) => {
|
||||
return userAgentNextServer({ headers }) as Omit<ReturnType<typeof userAgentNextServer>, "device"> & {
|
||||
device: Modify<ReturnType<typeof userAgentNextServer>["device"], { type: DeviceType }>;
|
||||
};
|
||||
};
|
||||
|
||||
export type DeviceType = "console" | "mobile" | "tablet" | "smarttv" | "wearable" | "embedded" | undefined;
|
||||
|
||||
export const ipAddressFromHeaders = (headers: Headers): string | null => {
|
||||
return headers.get("x-forwarded-for");
|
||||
};
|
||||
7
packages/common/src/revalidate-path-action.ts
Normal file
7
packages/common/src/revalidate-path-action.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export async function revalidatePathActionAsync(path: string) {
|
||||
return new Promise((resolve) => resolve(revalidatePath(path, "page")));
|
||||
}
|
||||
10
packages/common/src/security.ts
Normal file
10
packages/common/src/security.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
/**
|
||||
* Generates a random hex token twice the size of the given size
|
||||
* @param size amount of bytes to generate
|
||||
* @returns a random hex token twice the length of the given size
|
||||
*/
|
||||
export const generateSecureRandomToken = (size: number) => {
|
||||
return randomBytes(size).toString("hex");
|
||||
};
|
||||
4
packages/common/src/server.ts
Normal file
4
packages/common/src/server.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./security";
|
||||
export * from "./encryption";
|
||||
export * from "./request";
|
||||
export * from "./errors";
|
||||
51
packages/common/src/stopwatch.ts
Normal file
51
packages/common/src/stopwatch.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import dayjs from "dayjs";
|
||||
import duration from "dayjs/plugin/duration";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import updateLocale from "dayjs/plugin/updateLocale";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.extend(updateLocale);
|
||||
dayjs.extend(duration);
|
||||
|
||||
dayjs.updateLocale("en", {
|
||||
relativeTime: {
|
||||
future: "in %s",
|
||||
past: "%s ago",
|
||||
s: "one second",
|
||||
ss: "%d seconds",
|
||||
m: "a minute",
|
||||
mm: "%d minutes",
|
||||
h: "an hour",
|
||||
hh: "%d hours",
|
||||
d: "a day",
|
||||
dd: "%d days",
|
||||
M: "a month",
|
||||
MM: "%d months",
|
||||
y: "a year",
|
||||
yy: "%d years",
|
||||
},
|
||||
});
|
||||
|
||||
export class Stopwatch {
|
||||
private startTime: number;
|
||||
|
||||
constructor() {
|
||||
this.startTime = performance.now();
|
||||
}
|
||||
|
||||
getElapsedInHumanWords() {
|
||||
const difference = performance.now() - this.startTime;
|
||||
if (difference < 1000) {
|
||||
return `${Math.floor(difference)} ms`;
|
||||
}
|
||||
return dayjs().millisecond(this.startTime).fromNow(true);
|
||||
}
|
||||
|
||||
getElapsedInMilliseconds() {
|
||||
return performance.now() - this.startTime;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.startTime = performance.now();
|
||||
}
|
||||
}
|
||||
7
packages/common/src/string.ts
Normal file
7
packages/common/src/string.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const capitalize = <T extends string>(str: T) => {
|
||||
return (str.charAt(0).toUpperCase() + str.slice(1)) as Capitalize<T>;
|
||||
};
|
||||
|
||||
export const isNullOrWhitespace = (value: string | null): value is null => {
|
||||
return value == null || value.trim() === "";
|
||||
};
|
||||
49
packages/common/src/test/array.spec.ts
Normal file
49
packages/common/src/test/array.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { splitToChunksWithNItems, splitToNChunks } from "../array";
|
||||
|
||||
describe("splitToNChunks", () => {
|
||||
it("should split an array into the specified number of chunks", () => {
|
||||
const array = [1, 2, 3, 4, 5];
|
||||
const chunks = 3;
|
||||
const result = splitToNChunks(array, chunks);
|
||||
expect(result).toEqual([[1, 2], [3, 4], [5]]);
|
||||
});
|
||||
|
||||
it("should handle an empty array", () => {
|
||||
const array: number[] = [];
|
||||
const chunks = 3;
|
||||
const result = splitToNChunks(array, chunks);
|
||||
expect(result).toEqual([[], [], []]);
|
||||
});
|
||||
|
||||
it("should handle more chunks than elements", () => {
|
||||
const array = [1, 2];
|
||||
const chunks = 5;
|
||||
const result = splitToNChunks(array, chunks);
|
||||
expect(result).toEqual([[1], [2], [], [], []]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitToChunksWithNItems", () => {
|
||||
it("should split an array into chunks with the specified number of items", () => {
|
||||
const array = [1, 2, 3, 4, 5];
|
||||
const items = 2;
|
||||
const result = splitToChunksWithNItems(array, items);
|
||||
expect(result).toEqual([[1, 2], [3, 4], [5]]);
|
||||
});
|
||||
|
||||
it("should handle an empty array", () => {
|
||||
const array: number[] = [];
|
||||
const items = 2;
|
||||
const result = splitToChunksWithNItems(array, items);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle more items per chunk than elements", () => {
|
||||
const array = [1, 2];
|
||||
const items = 5;
|
||||
const result = splitToChunksWithNItems(array, items);
|
||||
expect(result).toEqual([[1, 2]]);
|
||||
});
|
||||
});
|
||||
91
packages/common/src/test/date.spec.ts
Normal file
91
packages/common/src/test/date.spec.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isDateWithin } from "../date";
|
||||
|
||||
describe("isDateWithin", () => {
|
||||
it("should return true for a date within the specified hours", () => {
|
||||
const date = new Date();
|
||||
date.setHours(date.getHours() - 20);
|
||||
expect(isDateWithin(date, "100h")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for a date outside the specified hours", () => {
|
||||
const date = new Date();
|
||||
date.setHours(date.getHours() - 101);
|
||||
expect(isDateWithin(date, "100h")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true for a date within the specified days", () => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - 5);
|
||||
expect(isDateWithin(date, "10d")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for a date outside the specified days", () => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - 11);
|
||||
expect(isDateWithin(date, "10d")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true for a date within the specified weeks", () => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - 10);
|
||||
expect(isDateWithin(date, "7w")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for a date outside the specified weeks", () => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - 50);
|
||||
expect(isDateWithin(date, "7w")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true for a date within the specified months", () => {
|
||||
const date = new Date();
|
||||
date.setMonth(date.getMonth() - 1);
|
||||
expect(isDateWithin(date, "2M")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for a date outside the specified months", () => {
|
||||
const date = new Date();
|
||||
date.setMonth(date.getMonth() - 3);
|
||||
expect(isDateWithin(date, "2M")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true for a date within the specified years", () => {
|
||||
const date = new Date();
|
||||
date.setFullYear(date.getFullYear() - 1);
|
||||
expect(isDateWithin(date, "2y")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for a date outside the specified years", () => {
|
||||
const date = new Date();
|
||||
date.setFullYear(date.getFullYear() - 3);
|
||||
expect(isDateWithin(date, "2y")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for a date after the specified relative time", () => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + 2);
|
||||
expect(isDateWithin(date, "1d")).toBe(false);
|
||||
});
|
||||
|
||||
it("should throw an error for an invalid unit", () => {
|
||||
const date = new Date();
|
||||
expect(() => isDateWithin(date, "2x")).toThrow("Invalid relative time unit");
|
||||
});
|
||||
|
||||
it("should throw an error if relativeDate is less than 2 characters long", () => {
|
||||
const date = new Date();
|
||||
expect(() => isDateWithin(date, "h")).toThrow("Relative date must be at least 2 characters long");
|
||||
});
|
||||
|
||||
it("should throw an error if relativeDate has an invalid number", () => {
|
||||
const date = new Date();
|
||||
expect(() => isDateWithin(date, "hh")).toThrow("Relative date must be a number greater than 0");
|
||||
});
|
||||
|
||||
it("should throw an error if relativeDate is set to 0", () => {
|
||||
const date = new Date();
|
||||
expect(() => isDateWithin(date, "0y")).toThrow("Relative date must be a number greater than 0");
|
||||
});
|
||||
});
|
||||
41
packages/common/src/test/error.spec.ts
Normal file
41
packages/common/src/test/error.spec.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { extractErrorMessage } from "../error";
|
||||
|
||||
describe("error to resolve to correct message", () => {
|
||||
test("error class to resolve to error message", () => {
|
||||
// Arrange
|
||||
const error = new Error("Message");
|
||||
|
||||
// Act
|
||||
const message = extractErrorMessage(error);
|
||||
|
||||
// Assert
|
||||
expect(typeof message).toBe("string");
|
||||
expect(message).toBe("Message");
|
||||
});
|
||||
|
||||
test("error string to resolve to error message", () => {
|
||||
// Arrange
|
||||
const error = "Message";
|
||||
|
||||
// Act
|
||||
const message = extractErrorMessage(error);
|
||||
|
||||
// Assert
|
||||
expect(typeof message).toBe("string");
|
||||
expect(message).toBe("Message");
|
||||
});
|
||||
|
||||
test("error whatever to resolve to unknown error message", () => {
|
||||
// Arrange
|
||||
const error = 5;
|
||||
|
||||
// Act
|
||||
const message = extractErrorMessage(error);
|
||||
|
||||
// Assert
|
||||
expect(typeof message).toBe("string");
|
||||
expect(message).toBe("Unknown error");
|
||||
});
|
||||
});
|
||||
21
packages/common/src/test/object.spec.ts
Normal file
21
packages/common/src/test/object.spec.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { objectEntries, objectKeys } from "../object";
|
||||
|
||||
const testObjects = [{ a: 1, c: 3, b: 2 }, { a: 1, b: 2 }, { a: 1 }, {}] as const;
|
||||
|
||||
describe("objectKeys should return all keys of an object", () => {
|
||||
testObjects.forEach((obj) => {
|
||||
it(`should return all keys of the object ${JSON.stringify(obj)}`, () => {
|
||||
expect(objectKeys(obj)).toEqual(Object.keys(obj));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("objectEntries should return all entries of an object", () => {
|
||||
testObjects.forEach((obj) => {
|
||||
it(`should return all entries of the object ${JSON.stringify(obj)}`, () => {
|
||||
expect(objectEntries(obj)).toEqual(Object.entries(obj));
|
||||
});
|
||||
});
|
||||
});
|
||||
19
packages/common/src/test/string.spec.ts
Normal file
19
packages/common/src/test/string.spec.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { capitalize } from "../string";
|
||||
|
||||
const capitalizeTestCases = [
|
||||
["hello", "Hello"],
|
||||
["World", "World"],
|
||||
["123", "123"],
|
||||
["a", "A"],
|
||||
["two words", "Two words"],
|
||||
] as const;
|
||||
|
||||
describe("capitalize should capitalize the first letter of a string", () => {
|
||||
capitalizeTestCases.forEach(([input, expected]) => {
|
||||
it(`should capitalize ${input} to ${expected}`, () => {
|
||||
expect(capitalize(input)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
40
packages/common/src/test/url.spec.ts
Normal file
40
packages/common/src/test/url.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getPortFromUrl } from "../url";
|
||||
|
||||
describe("getPortFromUrl", () => {
|
||||
test.each([
|
||||
[80, "http"],
|
||||
[443, "https"],
|
||||
])("should return %s for %s protocol without port", (expectedPort, protocol) => {
|
||||
// Arrange
|
||||
const url = new URL(`${protocol}://example.com`);
|
||||
|
||||
// Act
|
||||
const port = getPortFromUrl(url);
|
||||
|
||||
// Assert
|
||||
expect(port).toBe(expectedPort);
|
||||
});
|
||||
test.each([["http"], ["https"], ["anything"]])("should return the specified port for %s protocol", (protocol) => {
|
||||
// Arrange
|
||||
const expectedPort = 3000;
|
||||
const url = new URL(`${protocol}://example.com:${expectedPort}`);
|
||||
|
||||
// Act
|
||||
const port = getPortFromUrl(url);
|
||||
|
||||
// Assert
|
||||
expect(port).toBe(expectedPort);
|
||||
});
|
||||
test("should throw an error for unsupported protocol", () => {
|
||||
// Arrange
|
||||
const url = new URL("ftp://example.com");
|
||||
|
||||
// Act
|
||||
const act = () => getPortFromUrl(url);
|
||||
|
||||
// Act & Assert
|
||||
expect(act).toThrowError("Unsupported protocol: ftp:");
|
||||
});
|
||||
});
|
||||
5
packages/common/src/theme.ts
Normal file
5
packages/common/src/theme.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { DefaultMantineColor, MantineColorShade } from "@mantine/core";
|
||||
import { DEFAULT_THEME } from "@mantine/core";
|
||||
|
||||
export const getMantineColor = (color: DefaultMantineColor, shade: MantineColorShade) =>
|
||||
DEFAULT_THEME.colors[color]?.[shade] ?? "#fff";
|
||||
28
packages/common/src/types.ts
Normal file
28
packages/common/src/types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
export type MaybePromise<T> = T | Promise<T>;
|
||||
|
||||
export type AtLeastOneOf<T> = [T, ...T[]];
|
||||
|
||||
export type Modify<T, R extends Partial<Record<keyof T, unknown>>> = {
|
||||
[P in keyof (Omit<T, keyof R> & R)]: (Omit<T, keyof R> & R)[P];
|
||||
};
|
||||
|
||||
export type RemoveReadonly<T> = {
|
||||
-readonly [P in keyof T]: T[P] extends Record<string, unknown> ? RemoveReadonly<T[P]> : T[P];
|
||||
};
|
||||
|
||||
export type MaybeArray<T> = T | T[];
|
||||
export type Inverse<T extends Invertible> = {
|
||||
[Key in keyof T as T[Key]]: Key;
|
||||
};
|
||||
|
||||
type Invertible = Record<PropertyKey, PropertyKey>;
|
||||
|
||||
export type inferSearchParamsFromSchema<TSchema extends z.ZodObject> = inferSearchParamsFromSchemaInner<
|
||||
z.infer<TSchema>
|
||||
>;
|
||||
|
||||
type inferSearchParamsFromSchemaInner<TSchema extends Record<string, unknown>> = Partial<{
|
||||
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[] ? string[] : string;
|
||||
}>;
|
||||
40
packages/common/src/url.ts
Normal file
40
packages/common/src/url.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
|
||||
|
||||
export const removeTrailingSlash = (path: string) => {
|
||||
return path.at(-1) === "/" ? path.substring(0, path.length - 1) : path;
|
||||
};
|
||||
|
||||
export const extractBaseUrlFromHeaders = (
|
||||
headers: ReadonlyHeaders,
|
||||
fallbackProtocol: "http" | "https" = "http",
|
||||
): `${string}://${string}` => {
|
||||
// For empty string we also use the fallback protocol
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
let protocol = headers.get("x-forwarded-proto") || fallbackProtocol;
|
||||
|
||||
// @see https://support.glitch.com/t/x-forwarded-proto-contains-multiple-protocols/17219
|
||||
if (protocol.includes(",")) {
|
||||
protocol = protocol.includes("https") ? "https" : "http";
|
||||
}
|
||||
|
||||
const host = headers.get("x-forwarded-host") ?? headers.get("host");
|
||||
|
||||
return `${protocol}://${host}`;
|
||||
};
|
||||
|
||||
export const getPortFromUrl = (url: URL): number => {
|
||||
const port = url.port;
|
||||
if (port) {
|
||||
return Number(port);
|
||||
}
|
||||
|
||||
if (url.protocol === "https:") {
|
||||
return 443;
|
||||
}
|
||||
|
||||
if (url.protocol === "http:") {
|
||||
return 80;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported protocol: ${url.protocol}`);
|
||||
};
|
||||
8
packages/common/tsconfig.json
Normal file
8
packages/common/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@homarr/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user