diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.ts b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.ts index 5824751a4..1e90557b6 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.ts +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.ts @@ -2,13 +2,13 @@ import { fetchApi } from "@homarr/api/client"; import { mapWithConcurrency } from "@homarr/common"; import { customWidgetDefinitionSchema, + CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT, getCustomWidgetDefaultOptions, getCustomWidgetRequiredSecretKinds, parseCustomWidgetAiResponse, validateCustomWidgetOptions, } from "@homarr/custom-widgets/core"; import type { CustomWidgetSource, HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core"; -import { CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT } from "@homarr/custom-widgets/server"; import type { CustomWidgetFormValues } from "@homarr/custom-widgets/workbench"; import type { UseFormReturnType } from "@mantine/form"; diff --git a/packages/api/src/router/widgets/custom-api.ts b/packages/api/src/router/widgets/custom-api.ts index 160e3c953..43abce16d 100644 --- a/packages/api/src/router/widgets/custom-api.ts +++ b/packages/api/src/router/widgets/custom-api.ts @@ -14,7 +14,7 @@ import { validateCustomWidgetOptions, } from "@homarr/custom-widgets/core"; import type { CustomJsxRequest, CustomWidgetSource } from "@homarr/custom-widgets/core"; -import { CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT } from "@homarr/custom-widgets/server"; +import { CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT } from "@homarr/custom-widgets/core"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; import { throwIfActionForbiddenAsync } from "../board/board-access"; diff --git a/packages/custom-widgets/src/core/index.ts b/packages/custom-widgets/src/core/index.ts index 05f371794..78347d551 100644 --- a/packages/custom-widgets/src/core/index.ts +++ b/packages/custom-widgets/src/core/index.ts @@ -2,6 +2,7 @@ export * from "./bundled-widgets"; export * from "./examples"; export * from "./import"; export * from "./options"; +export * from "./request-limits"; export * from "./schema"; export * from "./schema-references"; export * from "./secret-requirements"; diff --git a/packages/custom-widgets/src/core/request-limits.ts b/packages/custom-widgets/src/core/request-limits.ts new file mode 100644 index 000000000..ac36ad2b1 --- /dev/null +++ b/packages/custom-widgets/src/core/request-limits.ts @@ -0,0 +1 @@ +export const CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT = 4; diff --git a/packages/custom-widgets/src/server/index.ts b/packages/custom-widgets/src/server/index.ts index 227aa28fa..5d4d15b63 100644 --- a/packages/custom-widgets/src/server/index.ts +++ b/packages/custom-widgets/src/server/index.ts @@ -19,7 +19,6 @@ export type { CustomWidgetAuthConfig, CustomWidgetHttpRequest, CustomWidgetHttpR export { CUSTOM_WIDGET_DEFINITION_CONCURRENCY_LIMIT, CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS, - CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT, CustomWidgetRequestLimiter, } from "./request-limits"; export type { RequestLimitInput, RequestLimiterOptions, RequestLimitStore } from "./request-limits"; diff --git a/packages/custom-widgets/src/server/request-executor.ts b/packages/custom-widgets/src/server/request-executor.ts index 24fa0ac50..82f660e36 100644 --- a/packages/custom-widgets/src/server/request-executor.ts +++ b/packages/custom-widgets/src/server/request-executor.ts @@ -13,7 +13,8 @@ import { validateCustomWidgetUrl, } from "./network-policy"; import { closeDispatcher } from "./request-dispatcher-lifecycle"; -import { parseResponseBody } from "./response"; +import { isCustomWidgetRequestTimeoutError } from "./request-timeout"; +import { normalizeResponseHeaders, parseResponseBody } from "./response"; export { assertSafeStaticHeaders, @@ -28,6 +29,7 @@ export { MAX_RESPONSE_JSON_DEPTH, MAX_RESPONSE_JSON_NODES, } from "./response"; +export { isCustomWidgetRequestTimeoutError } from "./request-timeout"; export const MAX_REQUEST_BODY_BYTES = 10 * 1024; const REQUEST_TIMEOUT_MS = 10_000; @@ -35,7 +37,6 @@ const MAX_QUERY_REDIRECTS = 3; const MAX_RESPONSE_CACHE_ENTRIES = 1_000; const MAX_RESPONSE_CACHE_BYTES = 64 * 1024 * 1024; export const MAX_REQUEST_DURATION_MS = 45_000; -const TIMEOUT_ERROR_CODES = new Set(["UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]); const RESPONSE_TOO_LARGE_ERROR_CODE = "UND_ERR_RES_EXCEEDED_MAX_SIZE"; export interface CustomWidgetAuthConfig { @@ -219,23 +220,6 @@ async function performRequestWithinDeadline( } } -export function isCustomWidgetRequestTimeoutError(error: unknown, ...signals: readonly AbortSignal[]): boolean { - if (signals.some((signal) => signal.aborted)) return true; - if (!(error instanceof Error)) return false; - if (error.name.includes("Timeout")) return true; - const code = "code" in error && typeof error.code === "string" ? error.code : undefined; - return code !== undefined && TIMEOUT_ERROR_CODES.has(code); -} - -function normalizeResponseHeaders(values: Record) { - const headers = new Headers(); - for (const [name, value] of Object.entries(values)) { - if (Array.isArray(value)) value.forEach((entry) => headers.append(name, entry)); - else if (value !== undefined) headers.set(name, value); - } - return headers; -} - function assertRequest(input: CustomWidgetHttpRequest): void { if (input.body !== undefined && Buffer.byteLength(input.body, "utf8") > MAX_REQUEST_BODY_BYTES) throw new CustomWidgetDomainError({ code: "PAYLOAD_TOO_LARGE", message: "Request body exceeds the 10 KiB limit" }); diff --git a/packages/custom-widgets/src/server/request-limits.ts b/packages/custom-widgets/src/server/request-limits.ts index 565d8ee84..768cc1d84 100644 --- a/packages/custom-widgets/src/server/request-limits.ts +++ b/packages/custom-widgets/src/server/request-limits.ts @@ -1,5 +1,6 @@ import { createHash, randomUUID } from "node:crypto"; +import { CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT } from "../core/request-limits"; import { CustomWidgetDomainError } from "./errors"; const WINDOW_MS = 60_000; @@ -7,7 +8,6 @@ const WINDOW_MS = 60_000; // Keep the lease above that total and identify every acquisition so a late // release can never remove capacity owned by a newer request. export const CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS = 60_000; -export const CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT = 4; export const CUSTOM_WIDGET_DEFINITION_CONCURRENCY_LIMIT = 8; const CATEGORY_LIMITS = { query: 60, action: 10, delete: 3 } as const; const DEFINITION_CATEGORY_LIMITS = { query: 240, action: 40, delete: 12 } as const; diff --git a/packages/custom-widgets/src/server/request-timeout.ts b/packages/custom-widgets/src/server/request-timeout.ts new file mode 100644 index 000000000..ed1989d4c --- /dev/null +++ b/packages/custom-widgets/src/server/request-timeout.ts @@ -0,0 +1,9 @@ +const TIMEOUT_ERROR_CODES = new Set(["UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]); + +export function isCustomWidgetRequestTimeoutError(error: unknown, ...signals: readonly AbortSignal[]): boolean { + if (signals.some((signal) => signal.aborted)) return true; + if (!(error instanceof Error)) return false; + if (error.name.includes("Timeout")) return true; + const code = "code" in error && typeof error.code === "string" ? error.code : undefined; + return code !== undefined && TIMEOUT_ERROR_CODES.has(code); +} diff --git a/packages/custom-widgets/src/server/response.ts b/packages/custom-widgets/src/server/response.ts index dec549d17..8d5a52ee1 100644 --- a/packages/custom-widgets/src/server/response.ts +++ b/packages/custom-widgets/src/server/response.ts @@ -1,3 +1,4 @@ +import { Headers } from "undici"; import type { Response } from "undici"; import { CustomWidgetDomainError } from "./errors"; @@ -5,6 +6,15 @@ export const MAX_RESPONSE_BODY_BYTES = 1024 * 1024; export const MAX_RESPONSE_JSON_DEPTH = 32; export const MAX_RESPONSE_JSON_NODES = 50_000; +export function normalizeResponseHeaders(values: Record) { + const headers = new Headers(); + for (const [name, value] of Object.entries(values)) { + if (Array.isArray(value)) value.forEach((entry) => headers.append(name, entry)); + else if (value !== undefined) headers.set(name, value); + } + return headers; +} + export function assertJsonBudget(value: unknown): void { let nodes = 0; const visit = (current: unknown, depth: number): void => {