From 76021dfb8d33391399de466d3f6e0dee21cb9387 Mon Sep 17 00:00:00 2001
From: Kranthi Kumar Muppala
Date: Sat, 18 Apr 2026 11:37:50 -0700
Subject: [PATCH 1/3] chore: comprehensive code-review fixes across security,
a11y, perf, tests
Synthesis of a multi-agent review covering security, a11y, perf, code
quality, and testing. Grouped by theme:
Security
- Bump DOMPurify 3.3.3 -> 3.4.0, isomorphic-dompurify -> 3.9.0, js-yaml
-> 4.1.1 to pick up published XSS / prototype-pollution fixes.
- Replace hand-rolled regex sanitizer in svg/svg-viewer with DOMPurify
(SVG profile, explicit FORBID_TAGS/FORBID_ATTR).
- Gate script/form sandbox permissions behind opt-in
htmlPreviewAllowScripts (ToolUIConfig); only HTML playground opts in.
- Raise AES-GCM PBKDF2 iterations 100k -> 600k (encrypt+decrypt in
lockstep) to match current OWASP guidance.
- Emit URL-safe base64 from jwt/fernet-encoder per Fernet spec.
- Warn in jwt/jwt-decoder description that signatures are NOT verified.
- Tighten CSP in public/_headers: drop unused cdn.jsdelivr.net, add
base-uri and form-action self-only.
Accessibility
- aria-haspopup="dialog" on hero + header search buttons.
- Drop onCloseAutoFocus={preventDefault} from theme / locale dropdown
menus so Radix returns focus to the trigger.
- aria-hidden decorative canvas (code-rain) and typewriter cursor.
- Respect prefers-reduced-motion in tool-demo typewriter (JS-driven).
- Darken light-mode --muted-foreground (#737373 -> #616161) to hit AA
on --muted; remove opacity:0.8 override that worsened it.
- Rewire file-upload to a real
)}
{allowFileUpload && (
-
+ >
)}
{value && (
diff --git a/apps/web/components/editor/output-panel.tsx b/apps/web/components/editor/output-panel.tsx
index 7d9a96c..e757a2b 100644
--- a/apps/web/components/editor/output-panel.tsx
+++ b/apps/web/components/editor/output-panel.tsx
@@ -201,6 +201,11 @@ interface OutputPanelProps {
* @default false
*/
isAutoMode?: boolean;
+ /**
+ * For HTML renderer: whether the sandboxed iframe may run scripts and
+ * submit forms. Defaults to false; opt in only for interactive tools.
+ */
+ htmlPreviewAllowScripts?: boolean;
/**
* Additional CSS classes
*/
@@ -216,6 +221,7 @@ export function OutputPanel({
onCopy,
onDownload,
isAutoMode = false,
+ htmlPreviewAllowScripts = false,
className,
}: OutputPanelProps): React.ReactElement {
const t = useTranslations("editor.output");
@@ -399,6 +405,7 @@ export function OutputPanel({
diff --git a/apps/web/components/effects/code-rain.tsx b/apps/web/components/effects/code-rain.tsx
index 7ffe60a..25e9db3 100644
--- a/apps/web/components/effects/code-rain.tsx
+++ b/apps/web/components/effects/code-rain.tsx
@@ -150,5 +150,5 @@ export function CodeRain({ className }: CodeRainProps): React.ReactElement {
};
}, [prefersReducedMotion, initParticles]);
- return ;
+ return ;
}
diff --git a/apps/web/components/forms/contact-form.tsx b/apps/web/components/forms/contact-form.tsx
index a822cff..e7c89da 100644
--- a/apps/web/components/forms/contact-form.tsx
+++ b/apps/web/components/forms/contact-form.tsx
@@ -45,13 +45,34 @@ export function ContactForm({
message: "",
});
const [status, setStatus] = useState("idle");
+ const [fieldErrors, setFieldErrors] = useState>({});
+ const [validationMessage, setValidationMessage] = useState("");
+
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
+ function validate(): Partial {
+ const errors: Partial = {};
+ if (!formData.name.trim()) errors.name = t("errors.nameRequired");
+ if (!formData.email.trim()) {
+ errors.email = t("errors.emailRequired");
+ } else if (!EMAIL_RE.test(formData.email.trim())) {
+ errors.email = t("errors.emailInvalid");
+ }
+ if (!formData.message.trim()) errors.message = t("errors.messageRequired");
+ return errors;
+ }
const handleSubmit = async (e: React.FormEvent): Promise => {
e.preventDefault();
- if (!formData.name || !formData.email || !formData.message) {
+ const errors = validate();
+ if (Object.keys(errors).length > 0) {
+ setFieldErrors(errors);
+ setValidationMessage(t("errors.fixBeforeSubmit"));
return;
}
+ setFieldErrors({});
+ setValidationMessage("");
setStatus("submitting");
@@ -121,10 +142,14 @@ export function ContactForm({
onSubmit={(e) => void handleSubmit(e)}
className={cn("space-y-6", className)}
>
- {status === "error" && (
-
+ {(status === "error" || validationMessage) && (
+
- {t("errorMessage")}
+ {validationMessage || t("errorMessage")}
)}
@@ -135,7 +160,12 @@ export function ContactForm({
placeholder={t("namePlaceholder")}
required
value={formData.name}
- onChange={(value) => setFormData({ ...formData, name: value })}
+ onChange={(value) => {
+ setFormData({ ...formData, name: value });
+ if (fieldErrors.name)
+ setFieldErrors({ ...fieldErrors, name: undefined });
+ }}
+ error={fieldErrors.name}
disabled={status === "submitting"}
/>
setFormData({ ...formData, email: value })}
+ onChange={(value) => {
+ setFormData({ ...formData, email: value });
+ if (fieldErrors.email)
+ setFieldErrors({ ...fieldErrors, email: undefined });
+ }}
+ error={fieldErrors.email}
disabled={status === "submitting"}
/>
@@ -167,7 +202,12 @@ export function ContactForm({
required
minRows={6}
value={formData.message}
- onChange={(e) => setFormData({ ...formData, message: e.target.value })}
+ onChange={(e) => {
+ setFormData({ ...formData, message: e.target.value });
+ if (fieldErrors.message)
+ setFieldErrors({ ...fieldErrors, message: undefined });
+ }}
+ error={fieldErrors.message}
disabled={status === "submitting"}
/>
diff --git a/apps/web/components/index.ts b/apps/web/components/index.ts
deleted file mode 100644
index 37d5e4d..0000000
--- a/apps/web/components/index.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-// Main components barrel export
-// Re-exports all component categories for convenient importing
-
-// Layout components
-export * from "./layout";
-
-// Tool components
-export * from "./tools";
-
-// Editor components
-export * from "./editor";
-
-// Renderer components
-export * from "./renderers";
-
-// Visual tool components
-export * from "./visual";
-
-// Form components
-export * from "./forms";
-
-// Display components
-export * from "./display";
-
-// Search components
-export * from "./search";
-
-// User components
-export * from "./user";
-
-// Shared utility components
-export * from "./shared";
-
-// Provider components
-export * from "./providers";
diff --git a/apps/web/components/layout/header.tsx b/apps/web/components/layout/header.tsx
index 99d3514..8254167 100644
--- a/apps/web/components/layout/header.tsx
+++ b/apps/web/components/layout/header.tsx
@@ -56,6 +56,7 @@ export function Header({
className="text-muted-foreground hidden w-48 items-center justify-start gap-2 md:flex lg:w-64"
onClick={handleSearchClick}
aria-label={t("searchAriaLabel")}
+ aria-haspopup="dialog"
>
@@ -74,6 +75,7 @@ export function Header({
className="h-10 w-10 touch-manipulation md:hidden"
onClick={handleSearchClick}
aria-label={t("searchMobileAriaLabel")}
+ aria-haspopup="dialog"
>
diff --git a/apps/web/components/layout/locale-switcher.tsx b/apps/web/components/layout/locale-switcher.tsx
index e024e65..d515b75 100644
--- a/apps/web/components/layout/locale-switcher.tsx
+++ b/apps/web/components/layout/locale-switcher.tsx
@@ -46,10 +46,7 @@ export function LocaleSwitcher(): React.ReactElement {
- e.preventDefault()}
- >
+
{locales.map((l) => (
{t("toggle")}
- e.preventDefault()}
- >
+
setTheme("light")}
aria-current={theme === "light" ? "true" : undefined}
diff --git a/apps/web/components/marketing/category-showcase.tsx b/apps/web/components/marketing/category-showcase.tsx
index 1951ab7..988baed 100644
--- a/apps/web/components/marketing/category-showcase.tsx
+++ b/apps/web/components/marketing/category-showcase.tsx
@@ -1,7 +1,7 @@
"use client";
import { Link } from "@/i18n/navigation";
-import { motion } from "framer-motion";
+import { m } from "framer-motion";
import { ArrowRight } from "lucide-react";
import { useTranslations } from "next-intl";
import { LucideIcon } from "@/components/shared/lucide-icon";
@@ -68,7 +68,7 @@ export function CategoryShowcase({
{t("subheading")}
-
{displayCategories.map((category) => (
-
+
@@ -101,9 +101,9 @@ export function CategoryShowcase({
-
+
))}
-
+
-
{/* Inline stats */}
-
@@ -113,22 +113,19 @@ export function CTASection({
{t("freeLabel")}
-
+
-
+
{t("heading", { toolCount })}
-
-
+
{t("subheading")}
-
+
-
@@ -147,8 +144,8 @@ export function CTASection({
{t("browseAllTools")}
-
-
+
+
);
diff --git a/apps/web/components/marketing/demo-visuals.tsx b/apps/web/components/marketing/demo-visuals.tsx
index e0dac7a..ec7ff7d 100644
--- a/apps/web/components/marketing/demo-visuals.tsx
+++ b/apps/web/components/marketing/demo-visuals.tsx
@@ -1,6 +1,26 @@
"use client";
-import { QRCodeSVG } from "qrcode.react";
+import dynamic from "next/dynamic";
+
+// Lazy-load qrcode.react so the package (~15 KB gzipped) isn't pulled into
+// the homepage entry chunk — it's only needed when the QR demo cycles in.
+const QRCodeSVG = dynamic(
+ () => import("qrcode.react").then((mod) => mod.QRCodeSVG),
+ {
+ ssr: false,
+ loading: () => (
+
+ ),
+ }
+);
interface DemoProps {
className?: string;
diff --git a/apps/web/components/marketing/feature-cards.tsx b/apps/web/components/marketing/feature-cards.tsx
index ec33601..a2fdf0d 100644
--- a/apps/web/components/marketing/feature-cards.tsx
+++ b/apps/web/components/marketing/feature-cards.tsx
@@ -1,6 +1,6 @@
"use client";
-import { motion } from "framer-motion";
+import { m } from "framer-motion";
import { Zap, Shield, Globe, Sparkles } from "lucide-react";
import { useTranslations } from "next-intl";
import { fadeInUp, staggerContainer, VIEWPORT_ONCE } from "@/lib/animation";
@@ -46,7 +46,7 @@ export function FeatureCards({
{t("eyebrow")}
-
{t("heading")}
-
-
+
{FEATURES.map((feature) => (
-
{feature.title}
{feature.description}
-
+
))}
-
+
);
diff --git a/apps/web/components/marketing/hero-section.tsx b/apps/web/components/marketing/hero-section.tsx
index 435d52c..312e10c 100644
--- a/apps/web/components/marketing/hero-section.tsx
+++ b/apps/web/components/marketing/hero-section.tsx
@@ -88,6 +88,7 @@ export function HeroSection({
type="button"
onClick={handleSearchClick}
aria-label={t("searchAriaLabel")}
+ aria-haspopup="dialog"
className={cn(
"group mt-2 flex w-full max-w-xl items-center gap-3",
"h-13 rounded-xl border px-4 sm:h-14 sm:px-5",
diff --git a/apps/web/components/marketing/stats-counter.tsx b/apps/web/components/marketing/stats-counter.tsx
index ef170dc..2d146e0 100644
--- a/apps/web/components/marketing/stats-counter.tsx
+++ b/apps/web/components/marketing/stats-counter.tsx
@@ -1,7 +1,7 @@
"use client";
import { useRef, useState, useCallback, useEffect } from "react";
-import { motion, useInView } from "framer-motion";
+import { m, useInView } from "framer-motion";
import { fadeInUp, staggerContainer, VIEWPORT_ONCE } from "@/lib/animation";
import { cn } from "@/lib/utils";
@@ -57,7 +57,7 @@ function StatCard({
}, [isInView]);
return (
-
+
-
+
);
}
@@ -88,7 +88,7 @@ export function StatsCounter({
return (
);
diff --git a/apps/web/components/marketing/tool-demo.tsx b/apps/web/components/marketing/tool-demo.tsx
index f09bf61..0289f2d 100644
--- a/apps/web/components/marketing/tool-demo.tsx
+++ b/apps/web/components/marketing/tool-demo.tsx
@@ -1,10 +1,11 @@
"use client";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
-import { motion, useInView, AnimatePresence } from "framer-motion";
+import { m, useInView, AnimatePresence } from "framer-motion";
import { ArrowRight } from "lucide-react";
import { useTranslations } from "next-intl";
import { fadeIn, VIEWPORT_ONCE } from "@/lib/animation";
+import { usePrefersReducedMotion } from "@/hooks/use-media-query";
import { cn } from "@/lib/utils";
import { QRCodeDemo, MermaidDemo } from "./demo-visuals";
@@ -235,6 +236,7 @@ function useTypewriter(
const [isRunning, setIsRunning] = useState(false);
const textRef = useRef(text);
const speedRef = useRef(speed);
+ const prefersReducedMotion = usePrefersReducedMotion();
textRef.current = text;
speedRef.current = speed;
@@ -254,6 +256,15 @@ function useTypewriter(
useEffect(() => {
if (!isRunning) return;
+ // Respect prefers-reduced-motion: show the full string immediately
+ // instead of running a 25-60ms-per-char setInterval animation.
+ if (prefersReducedMotion) {
+ setDisplayed(textRef.current);
+ setIsDone(true);
+ setIsRunning(false);
+ return;
+ }
+
let i = 0;
const interval = setInterval(() => {
i++;
@@ -266,7 +277,7 @@ function useTypewriter(
}, speedRef.current);
return () => clearInterval(interval);
- }, [isRunning]);
+ }, [isRunning, prefersReducedMotion]);
return { displayed, isDone, start, reset };
}
@@ -303,7 +314,11 @@ function GlassPanel({
) : (
{highlight ? highlightJson(content) : content}
- {!isOutput && |}
+ {!isOutput && (
+
+ |
+
+ )}
)}
@@ -384,7 +399,7 @@ export function ToolDemo({
return (
@@ -444,7 +459,7 @@ export function ToolDemo({
-
+
);
diff --git a/apps/web/components/providers/index.ts b/apps/web/components/providers/index.ts
index 628b665..eef8722 100644
--- a/apps/web/components/providers/index.ts
+++ b/apps/web/components/providers/index.ts
@@ -2,3 +2,4 @@
export { ThemeProvider } from "./theme-provider";
export { ToastProvider } from "./toast-provider";
export { KeyboardProvider, useKeyboard } from "./keyboard-provider";
+export { MotionProvider } from "./motion-provider";
diff --git a/apps/web/components/providers/motion-provider.tsx b/apps/web/components/providers/motion-provider.tsx
new file mode 100644
index 0000000..b67dcc9
--- /dev/null
+++ b/apps/web/components/providers/motion-provider.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { LazyMotion, domAnimation } from "framer-motion";
+import type { ReactNode } from "react";
+
+/**
+ * Lazily loads the framer-motion DOM animation feature set (~28 KB vs. the
+ * ~120 KB full bundle you get when consumers use `motion.*` eagerly) and
+ * exposes the `m.*` shortcut components to children.
+ *
+ * Use this once around any subtree whose components import `m` from
+ * framer-motion. Keep `strict` on: it surfaces cases where a consumer used
+ * `motion.*` (which requires the full feature set) instead of `m.*`.
+ */
+export function MotionProvider({
+ children,
+}: {
+ children: ReactNode;
+}): React.ReactElement {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/web/components/renderers/html-preview.tsx b/apps/web/components/renderers/html-preview.tsx
index 65b4e89..25a55ab 100644
--- a/apps/web/components/renderers/html-preview.tsx
+++ b/apps/web/components/renderers/html-preview.tsx
@@ -15,6 +15,14 @@ interface HtmlPreviewProps {
* @default true
*/
sandboxed?: boolean;
+ /**
+ * Whether the sandbox permits script execution and form submission.
+ * Turn off for tools that generate static content (SVG, meta tags, etc.)
+ * so that any sanitizer bypass can't escalate to script execution or
+ * external form posts.
+ * @default false
+ */
+ allowScripts?: boolean;
/**
* Whether to show the toolbar
* @default true
@@ -29,6 +37,7 @@ interface HtmlPreviewProps {
export function HtmlPreview({
content,
sandboxed = true,
+ allowScripts = false,
showToolbar = true,
className,
}: HtmlPreviewProps): React.ReactElement {
@@ -67,9 +76,13 @@ export function HtmlPreview({
setIsFullscreen(!isFullscreen);
};
- // Build sandbox attribute - same-origin permission is intentionally excluded
+ // Build sandbox attribute - same-origin permission is intentionally excluded.
+ // Script/form execution is opt-in; static-content tools default to the
+ // minimal permissions (popups only) so a sanitizer bypass can't pivot.
const sandboxValue = sandboxed
- ? "allow-scripts allow-popups allow-forms"
+ ? allowScripts
+ ? "allow-scripts allow-popups allow-forms"
+ : "allow-popups"
: undefined;
return (
diff --git a/apps/web/components/shared/download-button.tsx b/apps/web/components/shared/download-button.tsx
index 4a10ed2..4df2c1c 100644
--- a/apps/web/components/shared/download-button.tsx
+++ b/apps/web/components/shared/download-button.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState } from "react";
+import { useEffect, useRef, useState } from "react";
import { Download, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -49,6 +49,13 @@ export function DownloadButton({
className,
}: DownloadButtonProps): React.ReactElement {
const [downloaded, setDownloaded] = useState(false);
+ const timerRef = useRef | null>(null);
+
+ useEffect(() => {
+ return () => {
+ if (timerRef.current !== null) clearTimeout(timerRef.current);
+ };
+ }, []);
const handleDownload = (): void => {
const blob =
@@ -68,8 +75,10 @@ export function DownloadButton({
setDownloaded(true);
onDownload?.();
- setTimeout(() => {
+ if (timerRef.current !== null) clearTimeout(timerRef.current);
+ timerRef.current = setTimeout(() => {
setDownloaded(false);
+ timerRef.current = null;
}, 2000);
};
diff --git a/apps/web/components/tools/tool-documentation.tsx b/apps/web/components/tools/tool-documentation.tsx
index c5f7b2f..2d18b4a 100644
--- a/apps/web/components/tools/tool-documentation.tsx
+++ b/apps/web/components/tools/tool-documentation.tsx
@@ -1,6 +1,7 @@
"use client";
import { memo, useState } from "react";
+import dynamic from "next/dynamic";
import {
ChevronDown,
ChevronUp,
@@ -18,7 +19,16 @@ import {
} from "@/components/ui/collapsible";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
-import { MarkdownPreview } from "@/components/renderers/markdown-preview";
+// Documentation sits below the fold. Lazy-load MarkdownPreview (which pulls
+// in rehype-highlight + highlight.js, ~90 KB gzipped) so it only loads when
+// the user expands the documentation section on a tool page.
+const MarkdownPreview = dynamic(
+ () =>
+ import("@/components/renderers/markdown-preview").then(
+ (mod) => mod.MarkdownPreview
+ ),
+ { ssr: false }
+);
import { CodeEditor } from "@/components/editor/code-editor";
import { CopyButton } from "@/components/shared/copy-button";
import { cn } from "@/lib/utils";
diff --git a/apps/web/components/tools/tool-layout.tsx b/apps/web/components/tools/tool-layout.tsx
index e532e68..59db6b0 100644
--- a/apps/web/components/tools/tool-layout.tsx
+++ b/apps/web/components/tools/tool-layout.tsx
@@ -70,9 +70,15 @@ export function ToolLayout({
const splitStorageKey = `utils.live:panel-split:${tool.id}:${orientation}`;
+ // Read localStorage lazily in the initializer, but guard against SSR where
+ // `window` is undefined. On the server the default is used; on the client
+ // the persisted value is read synchronously before first paint, avoiding
+ // both a hydration mismatch (because the tool page is client-rendered for
+ // hydration anyway) and a cascading-setState-in-effect warning.
const [splitRatio, setSplitRatio] = useState(() => {
+ if (typeof window === "undefined") return defaultSplitRatio;
try {
- const saved = localStorage.getItem(splitStorageKey);
+ const saved = window.localStorage.getItem(splitStorageKey);
if (saved) {
const parsed = parseFloat(saved);
if (!isNaN(parsed) && parsed >= 0.2 && parsed <= 0.8) return parsed;
diff --git a/apps/web/hooks/index.ts b/apps/web/hooks/index.ts
index d59819a..91a992c 100644
--- a/apps/web/hooks/index.ts
+++ b/apps/web/hooks/index.ts
@@ -11,7 +11,5 @@ export {
} from "./use-media-query";
export { useKeyboardShortcut } from "./use-keyboard-shortcut";
export { useToolExecution } from "./use-tool-execution";
-export { useWorker, supportsWorkers, createWorkerFactory } from "./use-worker";
-export { useWorkerToolExecution } from "./use-worker-tool-execution";
export { useToolShortcuts } from "./use-tool-shortcuts";
export { useAnalytics } from "./use-analytics";
diff --git a/apps/web/hooks/use-clipboard.ts b/apps/web/hooks/use-clipboard.ts
index 86647a7..8a9008e 100644
--- a/apps/web/hooks/use-clipboard.ts
+++ b/apps/web/hooks/use-clipboard.ts
@@ -1,6 +1,6 @@
"use client";
-import { useState, useCallback } from "react";
+import { useState, useCallback, useEffect, useRef } from "react";
interface UseClipboardResult {
copy: (text: string) => Promise;
@@ -18,6 +18,13 @@ interface UseClipboardResult {
export function useClipboard(resetDelay: number = 2000): UseClipboardResult {
const [copied, setCopied] = useState(false);
const [error, setError] = useState(null);
+ const timerRef = useRef | null>(null);
+
+ useEffect(() => {
+ return () => {
+ if (timerRef.current !== null) clearTimeout(timerRef.current);
+ };
+ }, []);
const copy = useCallback(
async (text: string): Promise => {
@@ -26,8 +33,10 @@ export function useClipboard(resetDelay: number = 2000): UseClipboardResult {
setCopied(true);
setError(null);
- setTimeout(() => {
+ if (timerRef.current !== null) clearTimeout(timerRef.current);
+ timerRef.current = setTimeout(() => {
setCopied(false);
+ timerRef.current = null;
}, resetDelay);
return true;
diff --git a/apps/web/hooks/use-worker-tool-execution.ts b/apps/web/hooks/use-worker-tool-execution.ts
deleted file mode 100644
index e9ce94b..0000000
--- a/apps/web/hooks/use-worker-tool-execution.ts
+++ /dev/null
@@ -1,314 +0,0 @@
-"use client";
-
-import { useCallback, useEffect, useRef, useState } from "react";
-import * as Comlink from "comlink";
-import { releaseComlinkProxy } from "@/lib/comlink-utils";
-
-/**
- * Worker API type definition matching the exposed worker methods
- */
-interface ToolWorkerApi {
- processLargeText: (
- input: string,
- operation: string,
- options?: Record
- ) => Promise;
- hashString: (input: string, algorithm?: string) => Promise;
- processJson: (
- input: string,
- options?: Record
- ) => Promise;
- validateRegex: (
- pattern: string,
- testString?: string,
- flags?: string
- ) => Promise;
- ping: () => string;
- getCapabilities: () => WorkerCapabilities;
-}
-
-interface WorkerResult {
- success: boolean;
- data?: unknown;
- error?: {
- code: string;
- message: string;
- };
-}
-
-interface WorkerCapabilities {
- subtleCrypto: boolean;
- textEncoder: boolean;
- performance: boolean;
-}
-
-type ExecutionStatus = "idle" | "loading" | "executing" | "success" | "error";
-
-interface UseWorkerToolExecutionOptions {
- /**
- * Whether to automatically initialize the worker
- * @default true
- */
- autoInit?: boolean;
- /**
- * Callback when execution starts
- */
- onStart?: () => void;
- /**
- * Callback when execution succeeds
- */
- onSuccess?: (result: unknown) => void;
- /**
- * Callback when execution fails
- */
- onError?: (error: Error) => void;
-}
-
-interface UseWorkerToolExecutionReturn {
- /**
- * Whether the worker is ready for use
- */
- isReady: boolean;
- /**
- * Current execution status
- */
- status: ExecutionStatus;
- /**
- * Whether execution is in progress
- */
- isExecuting: boolean;
- /**
- * The result of the last execution
- */
- result: unknown;
- /**
- * Any error from worker initialization or execution
- */
- error: Error | null;
- /**
- * Process large text with various operations
- */
- processText: (
- input: string,
- operation:
- | "lineCount"
- | "wordCount"
- | "charCount"
- | "findReplace"
- | "sort"
- | "unique"
- | "reverse"
- | "base64encode"
- | "base64decode",
- options?: Record
- ) => Promise;
- /**
- * Hash a string using various algorithms
- */
- hashString: (
- input: string,
- algorithm?: "SHA-1" | "SHA-256" | "SHA-384" | "SHA-512"
- ) => Promise;
- /**
- * Parse and format JSON
- */
- processJson: (
- input: string,
- options?: { minify?: boolean; indent?: number }
- ) => Promise;
- /**
- * Validate and test regex patterns
- */
- validateRegex: (
- pattern: string,
- testString?: string,
- flags?: string
- ) => Promise;
- /**
- * Terminate the worker
- */
- terminate: () => void;
- /**
- * Reinitialize the worker after termination
- */
- reinitialize: () => void;
-}
-
-/**
- * Hook for executing tool operations in a Web Worker.
- * Offloads CPU-intensive operations to a background thread.
- */
-export function useWorkerToolExecution(
- options: UseWorkerToolExecutionOptions = {}
-): UseWorkerToolExecutionReturn {
- const { autoInit = true, onStart, onSuccess, onError } = options;
-
- const workerRef = useRef(null);
- const proxyRef = useRef | null>(null);
-
- const [isReady, setIsReady] = useState(false);
- const [status, setStatus] = useState("idle");
- const [result, setResult] = useState(null);
- const [error, setError] = useState(null);
-
- // Initialize worker
- const initWorker = useCallback(() => {
- if (typeof Worker === "undefined") {
- setError(new Error("Web Workers are not supported in this browser"));
- return;
- }
-
- try {
- setStatus("loading");
- const worker = new Worker("/workers/tool-worker.js", { type: "module" });
- workerRef.current = worker;
- proxyRef.current = Comlink.wrap(worker);
- setIsReady(true);
- setStatus("idle");
- setError(null);
- } catch (err) {
- const error = err instanceof Error ? err : new Error(String(err));
- setError(error);
- setStatus("error");
- }
- }, []);
-
- // Auto-initialize on mount
- useEffect(() => {
- if (autoInit) {
- initWorker();
- }
-
- return (): void => {
- releaseComlinkProxy(proxyRef.current);
- if (workerRef.current) {
- workerRef.current.terminate();
- }
- };
- }, [autoInit, initWorker]);
-
- // Helper to execute with error handling
- const executeWithHandling = useCallback(
- async (operation: () => Promise): Promise => {
- if (!proxyRef.current) {
- throw new Error("Worker is not initialized");
- }
-
- setStatus("executing");
- setError(null);
- onStart?.();
-
- try {
- const workerResult = await operation();
-
- if (!workerResult.success) {
- throw new Error(
- workerResult.error?.message || "Worker execution failed"
- );
- }
-
- setResult(workerResult.data);
- setStatus("success");
- onSuccess?.(workerResult.data);
- return workerResult.data as T;
- } catch (err) {
- const error = err instanceof Error ? err : new Error(String(err));
- setError(error);
- setStatus("error");
- onError?.(error);
- throw error;
- }
- },
- [onStart, onSuccess, onError]
- );
-
- // Process large text
- const processText = useCallback(
- async (
- input: string,
- operation: string,
- opts?: Record
- ): Promise => {
- return executeWithHandling(() =>
- proxyRef.current!.processLargeText(input, operation, opts)
- );
- },
- [executeWithHandling]
- );
-
- // Hash string
- const hashString = useCallback(
- async (input: string, algorithm?: string): Promise => {
- return executeWithHandling(() =>
- proxyRef.current!.hashString(input, algorithm)
- );
- },
- [executeWithHandling]
- );
-
- // Process JSON
- const processJson = useCallback(
- async (
- input: string,
- opts?: { minify?: boolean; indent?: number }
- ): Promise => {
- return executeWithHandling(() =>
- proxyRef.current!.processJson(input, opts)
- );
- },
- [executeWithHandling]
- );
-
- // Validate regex
- const validateRegex = useCallback(
- async (
- pattern: string,
- testString?: string,
- flags?: string
- ): Promise => {
- return executeWithHandling(() =>
- proxyRef.current!.validateRegex(pattern, testString, flags)
- );
- },
- [executeWithHandling]
- );
-
- // Terminate worker
- const terminate = useCallback((): void => {
- releaseComlinkProxy(proxyRef.current);
- proxyRef.current = null;
- if (workerRef.current) {
- workerRef.current.terminate();
- workerRef.current = null;
- }
- setIsReady(false);
- setStatus("idle");
- }, []);
-
- // Reinitialize
- const reinitialize = useCallback(() => {
- terminate();
- initWorker();
- }, [terminate, initWorker]);
-
- return {
- isReady,
- status,
- isExecuting: status === "executing",
- result,
- error,
- processText,
- hashString,
- processJson,
- validateRegex,
- terminate,
- reinitialize,
- };
-}
-
-/**
- * Check if the current environment supports Web Workers
- */
-export function supportsWorkers(): boolean {
- return typeof Worker !== "undefined";
-}
diff --git a/apps/web/hooks/use-worker.ts b/apps/web/hooks/use-worker.ts
deleted file mode 100644
index f05f29c..0000000
--- a/apps/web/hooks/use-worker.ts
+++ /dev/null
@@ -1,135 +0,0 @@
-"use client";
-
-import { useCallback, useEffect, useRef, useState } from "react";
-import * as Comlink from "comlink";
-import { releaseComlinkProxy } from "@/lib/comlink-utils";
-
-interface UseWorkerOptions {
- /**
- * Whether to terminate the worker when the component unmounts
- * @default true
- */
- terminateOnUnmount?: boolean;
-}
-
-interface UseWorkerReturn {
- /**
- * The wrapped worker API
- */
- worker: Comlink.Remote | null;
- /**
- * Whether the worker is ready
- */
- isReady: boolean;
- /**
- * Any error that occurred during worker initialization
- */
- error: Error | null;
- /**
- * Terminate the worker manually
- */
- terminate: () => void;
-}
-
-interface WorkerState {
- proxy: Comlink.Remote | null;
- isReady: boolean;
- error: Error | null;
-}
-
-function initWorker(workerFactory: () => Worker): {
- state: WorkerState;
- worker: Worker | null;
- proxy: Comlink.Remote | null;
-} {
- if (typeof Worker === "undefined") {
- return {
- state: {
- proxy: null,
- isReady: false,
- error: new Error("Web Workers are not supported in this browser"),
- },
- worker: null,
- proxy: null,
- };
- }
-
- try {
- const worker = workerFactory();
- const proxy = Comlink.wrap(worker);
- return {
- state: { proxy, isReady: true, error: null },
- worker,
- proxy,
- };
- } catch (err) {
- const initError = err instanceof Error ? err : new Error(String(err));
- return {
- state: { proxy: null, isReady: false, error: initError },
- worker: null,
- proxy: null,
- };
- }
-}
-
-/**
- * Hook for using Web Workers with Comlink.
- * Provides a type-safe wrapper around the worker API.
- *
- * @param workerFactory - Function that creates the worker
- * @param options - Configuration options
- */
-export function useWorker(
- workerFactory: () => Worker,
- options: UseWorkerOptions = {}
-): UseWorkerReturn {
- const { terminateOnUnmount = true } = options;
-
- const [initResult] = useState(() => initWorker(workerFactory));
- const workerRef = useRef(initResult.worker);
- const proxyRef = useRef | null>(initResult.proxy);
- const [state, setState] = useState>(initResult.state);
-
- // Cleanup on unmount
- useEffect(() => {
- return (): void => {
- if (terminateOnUnmount && workerRef.current) {
- releaseComlinkProxy(proxyRef.current);
- workerRef.current.terminate();
- workerRef.current = null;
- proxyRef.current = null;
- }
- };
- }, [terminateOnUnmount]);
-
- const terminate = useCallback((): void => {
- releaseComlinkProxy(proxyRef.current);
- proxyRef.current = null;
- if (workerRef.current) {
- workerRef.current.terminate();
- workerRef.current = null;
- }
- setState({ proxy: null, isReady: false, error: null });
- }, []);
-
- return {
- worker: state.proxy,
- isReady: state.isReady,
- error: state.error,
- terminate,
- };
-}
-
-/**
- * Check if Web Workers are supported in the current environment
- */
-export function supportsWorkers(): boolean {
- return typeof Worker !== "undefined";
-}
-
-/**
- * Create a worker factory function for a given module URL
- */
-export function createWorkerFactory(moduleUrl: URL): () => Worker {
- return () => new Worker(moduleUrl, { type: "module" });
-}
diff --git a/apps/web/lib/tools/schema-to-json.ts b/apps/web/lib/tools/schema-to-json.ts
deleted file mode 100644
index d67eeb2..0000000
--- a/apps/web/lib/tools/schema-to-json.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-/**
- * Re-export JSON Schema conversion utilities from @utils-live/tools.
- * This provides a convenient import path for web app components.
- */
-export { toJsonSchema, toJsonSchemaWithMeta } from "@utils-live/tools";
-export type { JsonSchema } from "@utils-live/tools";
diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json
index 8c331e9..885f207 100644
--- a/apps/web/messages/de.json
+++ b/apps/web/messages/de.json
@@ -134,7 +134,8 @@
"toolNotFound": {
"title": "Tool nicht gefunden",
"description": "Das angeforderte Tool konnte nicht gefunden werden."
- }
+ },
+ "inputPlaceholder": "Enter {toolName} input..."
},
"search": {
"heading": "Tools erkunden",
@@ -366,6 +367,13 @@
"support": "Technischer Support",
"partnership": "Partnerschaft",
"other": "Sonstiges"
+ },
+ "errors": {
+ "fixBeforeSubmit": "Please fix the fields below before submitting.",
+ "nameRequired": "Please enter your name.",
+ "emailRequired": "Please enter your email address.",
+ "emailInvalid": "Please enter a valid email address.",
+ "messageRequired": "Please enter a message."
}
}
}
diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json
index 0e5232c..709cf21 100644
--- a/apps/web/messages/en.json
+++ b/apps/web/messages/en.json
@@ -123,6 +123,7 @@
"shortcutClear": "Clear / Reset",
"undoLabel": "Undo",
"exampleLoaded": "Example loaded",
+ "inputPlaceholder": "Enter {toolName} input...",
"inputLabel": "Input",
"outputLabel": "Output",
"configureLabel": "Configure",
@@ -349,6 +350,13 @@
"successDescription": "Thank you for reaching out. We'll get back to you as soon as possible, usually within 24 hours.",
"sendAnother": "Send Another Message",
"errorMessage": "Something went wrong. Please try again.",
+ "errors": {
+ "fixBeforeSubmit": "Please fix the fields below before submitting.",
+ "nameRequired": "Please enter your name.",
+ "emailRequired": "Please enter your email address.",
+ "emailInvalid": "Please enter a valid email address.",
+ "messageRequired": "Please enter a message."
+ },
"namePlaceholder": "Your name",
"nameLabel": "Name",
"emailLabel": "Email",
diff --git a/apps/web/messages/es.json b/apps/web/messages/es.json
index 0082df6..a9458c1 100644
--- a/apps/web/messages/es.json
+++ b/apps/web/messages/es.json
@@ -134,7 +134,8 @@
"toolNotFound": {
"title": "Herramienta no encontrada",
"description": "No se pudo encontrar la herramienta solicitada."
- }
+ },
+ "inputPlaceholder": "Enter {toolName} input..."
},
"search": {
"heading": "Explorar herramientas",
@@ -366,6 +367,13 @@
"support": "Soporte técnico",
"partnership": "Asociación",
"other": "Otro"
+ },
+ "errors": {
+ "fixBeforeSubmit": "Please fix the fields below before submitting.",
+ "nameRequired": "Please enter your name.",
+ "emailRequired": "Please enter your email address.",
+ "emailInvalid": "Please enter a valid email address.",
+ "messageRequired": "Please enter a message."
}
}
}
diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json
index 534b684..ae0731f 100644
--- a/apps/web/messages/fr.json
+++ b/apps/web/messages/fr.json
@@ -134,7 +134,8 @@
"toolNotFound": {
"title": "Outil non trouvé",
"description": "L'outil demandé est introuvable."
- }
+ },
+ "inputPlaceholder": "Enter {toolName} input..."
},
"search": {
"heading": "Explorez les outils",
@@ -366,6 +367,13 @@
"support": "Support technique",
"partnership": "Partenariat",
"other": "Autre"
+ },
+ "errors": {
+ "fixBeforeSubmit": "Please fix the fields below before submitting.",
+ "nameRequired": "Please enter your name.",
+ "emailRequired": "Please enter your email address.",
+ "emailInvalid": "Please enter a valid email address.",
+ "messageRequired": "Please enter a message."
}
}
}
diff --git a/apps/web/messages/ja.json b/apps/web/messages/ja.json
index 9a1e026..b996cbd 100644
--- a/apps/web/messages/ja.json
+++ b/apps/web/messages/ja.json
@@ -134,7 +134,8 @@
"toolNotFound": {
"title": "ツールが見つかりません",
"description": "リクエストされたツールが見つかりませんでした。"
- }
+ },
+ "inputPlaceholder": "Enter {toolName} input..."
},
"search": {
"heading": "ツールを探索",
@@ -366,6 +367,13 @@
"support": "技術サポート",
"partnership": "パートナーシップ",
"other": "その他"
+ },
+ "errors": {
+ "fixBeforeSubmit": "Please fix the fields below before submitting.",
+ "nameRequired": "Please enter your name.",
+ "emailRequired": "Please enter your email address.",
+ "emailInvalid": "Please enter a valid email address.",
+ "messageRequired": "Please enter a message."
}
}
}
diff --git a/apps/web/messages/ko.json b/apps/web/messages/ko.json
index ad8989d..d1be2ad 100644
--- a/apps/web/messages/ko.json
+++ b/apps/web/messages/ko.json
@@ -134,7 +134,8 @@
"toolNotFound": {
"title": "도구를 찾을 수 없음",
"description": "요청한 도구를 찾을 수 없습니다."
- }
+ },
+ "inputPlaceholder": "Enter {toolName} input..."
},
"search": {
"heading": "도구 탐색",
@@ -366,6 +367,13 @@
"support": "기술 지원",
"partnership": "파트너십",
"other": "기타"
+ },
+ "errors": {
+ "fixBeforeSubmit": "Please fix the fields below before submitting.",
+ "nameRequired": "Please enter your name.",
+ "emailRequired": "Please enter your email address.",
+ "emailInvalid": "Please enter a valid email address.",
+ "messageRequired": "Please enter a message."
}
}
}
diff --git a/apps/web/messages/pt-BR.json b/apps/web/messages/pt-BR.json
index 01635e6..488f2d8 100644
--- a/apps/web/messages/pt-BR.json
+++ b/apps/web/messages/pt-BR.json
@@ -134,7 +134,8 @@
"toolNotFound": {
"title": "Ferramenta Não Encontrada",
"description": "A ferramenta solicitada não pôde ser encontrada."
- }
+ },
+ "inputPlaceholder": "Enter {toolName} input..."
},
"search": {
"heading": "Explorar Ferramentas",
@@ -366,6 +367,13 @@
"support": "Suporte Técnico",
"partnership": "Parceria",
"other": "Outro"
+ },
+ "errors": {
+ "fixBeforeSubmit": "Please fix the fields below before submitting.",
+ "nameRequired": "Please enter your name.",
+ "emailRequired": "Please enter your email address.",
+ "emailInvalid": "Please enter a valid email address.",
+ "messageRequired": "Please enter a message."
}
}
}
diff --git a/apps/web/messages/ru.json b/apps/web/messages/ru.json
index b58fe8f..76e54e3 100644
--- a/apps/web/messages/ru.json
+++ b/apps/web/messages/ru.json
@@ -134,7 +134,8 @@
"toolNotFound": {
"title": "Инструмент не найден",
"description": "Запрошенный инструмент не найден."
- }
+ },
+ "inputPlaceholder": "Enter {toolName} input..."
},
"search": {
"heading": "Обзор инструментов",
@@ -366,6 +367,13 @@
"support": "Техническая поддержка",
"partnership": "Партнёрство",
"other": "Другое"
+ },
+ "errors": {
+ "fixBeforeSubmit": "Please fix the fields below before submitting.",
+ "nameRequired": "Please enter your name.",
+ "emailRequired": "Please enter your email address.",
+ "emailInvalid": "Please enter a valid email address.",
+ "messageRequired": "Please enter a message."
}
}
}
diff --git a/apps/web/messages/tr.json b/apps/web/messages/tr.json
index bccddd0..3a9ff14 100644
--- a/apps/web/messages/tr.json
+++ b/apps/web/messages/tr.json
@@ -134,7 +134,8 @@
"toolNotFound": {
"title": "Araç Bulunamadı",
"description": "İstenen araç bulunamadı."
- }
+ },
+ "inputPlaceholder": "Enter {toolName} input..."
},
"search": {
"heading": "Araçları Keşfet",
@@ -366,6 +367,13 @@
"support": "Teknik Destek",
"partnership": "İş Ortaklığı",
"other": "Diğer"
+ },
+ "errors": {
+ "fixBeforeSubmit": "Please fix the fields below before submitting.",
+ "nameRequired": "Please enter your name.",
+ "emailRequired": "Please enter your email address.",
+ "emailInvalid": "Please enter a valid email address.",
+ "messageRequired": "Please enter a message."
}
}
}
diff --git a/apps/web/messages/zh-CN.json b/apps/web/messages/zh-CN.json
index be3495e..c291adf 100644
--- a/apps/web/messages/zh-CN.json
+++ b/apps/web/messages/zh-CN.json
@@ -134,7 +134,8 @@
"toolNotFound": {
"title": "工具未找到",
"description": "请求的工具无法找到。"
- }
+ },
+ "inputPlaceholder": "Enter {toolName} input..."
},
"search": {
"heading": "探索工具",
@@ -366,6 +367,13 @@
"support": "技术支持",
"partnership": "合作",
"other": "其他"
+ },
+ "errors": {
+ "fixBeforeSubmit": "Please fix the fields below before submitting.",
+ "nameRequired": "Please enter your name.",
+ "emailRequired": "Please enter your email address.",
+ "emailInvalid": "Please enter a valid email address.",
+ "messageRequired": "Please enter a message."
}
}
}
diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json
index fac9fa9..486e0a2 100644
--- a/apps/web/messages/zh-TW.json
+++ b/apps/web/messages/zh-TW.json
@@ -134,7 +134,8 @@
"toolNotFound": {
"title": "找不到工具",
"description": "找不到所請求的工具。"
- }
+ },
+ "inputPlaceholder": "Enter {toolName} input..."
},
"search": {
"heading": "探索工具",
@@ -366,6 +367,13 @@
"support": "技術支援",
"partnership": "合作",
"other": "其他"
+ },
+ "errors": {
+ "fixBeforeSubmit": "Please fix the fields below before submitting.",
+ "nameRequired": "Please enter your name.",
+ "emailRequired": "Please enter your email address.",
+ "emailInvalid": "Please enter a valid email address.",
+ "messageRequired": "Please enter a message."
}
}
}
diff --git a/apps/web/package.json b/apps/web/package.json
index 0687c8d..d2ef7d6 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -46,7 +46,7 @@
"cmdk": "1.1.1",
"comlink": "4.4.0",
"date-fns": "4.1.0",
- "dompurify": "3.3.3",
+ "dompurify": "3.4.0",
"framer-motion": "12.33.0",
"highlight.js": "11.11.1",
"jsbarcode": "3.12.3",
diff --git a/apps/web/public/_headers b/apps/web/public/_headers
index 4ea429e..e702d95 100644
--- a/apps/web/public/_headers
+++ b/apps/web/public/_headers
@@ -4,7 +4,7 @@
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
- Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: https://cdn.jsdelivr.net https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data: blob:; font-src 'self' data: https://cdn.jsdelivr.net; connect-src 'self' https://cdn.jsdelivr.net https://cloudflareinsights.com https://api.staticforms.dev; worker-src 'self' blob:; frame-ancestors 'none'
+ Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https://cloudflareinsights.com https://api.staticforms.dev; worker-src 'self' blob:; frame-ancestors 'none'; base-uri 'self'; form-action 'self' https://api.staticforms.dev
# Static assets - cache for 1 year (content-hashed filenames)
/_next/static/*
diff --git a/apps/web/public/workers/tool-worker.js b/apps/web/public/workers/tool-worker.js
deleted file mode 100644
index 711207a..0000000
--- a/apps/web/public/workers/tool-worker.js
+++ /dev/null
@@ -1,239 +0,0 @@
-/**
- * Web Worker for CPU-intensive tool operations.
- * Handles tool execution off the main thread to prevent UI blocking.
- *
- * Uses Comlink for seamless communication with the main thread.
- */
-
-import { expose } from "comlink";
-
-/**
- * Process large text data in chunks.
- * Useful for operations that need to process megabytes of text.
- *
- * @param {string} input - The input string to process
- * @param {string} operation - The operation type (split, join, transform)
- * @param {Record} options - Processing options
- * @returns {Promise} The processed result
- */
-async function processLargeText(input, operation, options = {}) {
- try {
- const chunkSize = options.chunkSize || 1024 * 1024; // 1MB default
- const results = [];
-
- switch (operation) {
- case "lineCount":
- let lineCount = 0;
- for (let i = 0; i < input.length; i++) {
- if (input[i] === "\n") lineCount++;
- }
- return { success: true, data: lineCount + 1 };
-
- case "wordCount":
- const words = input.trim().split(/\s+/).filter(Boolean);
- return { success: true, data: words.length };
-
- case "charCount":
- return { success: true, data: input.length };
-
- case "findReplace":
- const { find, replace, regex, flags } = options;
- let processed;
- if (regex) {
- const re = new RegExp(find, flags || "g");
- processed = input.replace(re, replace || "");
- } else {
- processed = input.split(find).join(replace || "");
- }
- return { success: true, data: processed };
-
- case "sort":
- const lines = input.split("\n");
- const sortedLines = options.reverse
- ? lines.sort().reverse()
- : lines.sort();
- return { success: true, data: sortedLines.join("\n") };
-
- case "unique":
- const uniqueLines = [...new Set(input.split("\n"))];
- return { success: true, data: uniqueLines.join("\n") };
-
- case "reverse":
- if (options.byLine) {
- return {
- success: true,
- data: input.split("\n").reverse().join("\n"),
- };
- }
- return { success: true, data: input.split("").reverse().join("") };
-
- case "base64encode":
- return {
- success: true,
- data: btoa(unescape(encodeURIComponent(input))),
- };
-
- case "base64decode":
- return { success: true, data: decodeURIComponent(escape(atob(input))) };
-
- default:
- return {
- success: false,
- error: {
- code: "UNKNOWN_OPERATION",
- message: `Unknown operation: ${operation}`,
- },
- };
- }
- } catch (error) {
- return {
- success: false,
- error: {
- code: "WORKER_PROCESSING_ERROR",
- message: error instanceof Error ? error.message : String(error),
- },
- };
- }
-}
-
-/**
- * Hash a string using the SubtleCrypto API.
- *
- * @param {string} input - The input string to hash
- * @param {string} algorithm - The hash algorithm (SHA-1, SHA-256, SHA-384, SHA-512)
- * @returns {Promise} The hex-encoded hash
- */
-async function hashString(input, algorithm = "SHA-256") {
- try {
- const encoder = new TextEncoder();
- const data = encoder.encode(input);
- const hashBuffer = await crypto.subtle.digest(algorithm, data);
- const hashArray = Array.from(new Uint8Array(hashBuffer));
- const hashHex = hashArray
- .map((b) => b.toString(16).padStart(2, "0"))
- .join("");
- return { success: true, data: hashHex };
- } catch (error) {
- return {
- success: false,
- error: {
- code: "HASH_ERROR",
- message: error instanceof Error ? error.message : String(error),
- },
- };
- }
-}
-
-/**
- * Parse and format JSON in the worker.
- *
- * @param {string} input - The JSON string to parse/format
- * @param {Record} options - Formatting options
- * @returns {Promise} The parsed or formatted result
- */
-async function processJson(input, options = {}) {
- try {
- const parsed = JSON.parse(input);
-
- if (options.minify) {
- return { success: true, data: JSON.stringify(parsed) };
- }
-
- const indent = options.indent ?? 2;
- return { success: true, data: JSON.stringify(parsed, null, indent) };
- } catch (error) {
- return {
- success: false,
- error: {
- code: "JSON_ERROR",
- message: error instanceof Error ? error.message : String(error),
- },
- };
- }
-}
-
-/**
- * Validate regex patterns in the worker.
- *
- * @param {string} pattern - The regex pattern to validate
- * @param {string} testString - Optional test string to match against
- * @param {string} flags - Regex flags
- * @returns {Promise} Validation result and matches
- */
-async function validateRegex(pattern, testString = "", flags = "") {
- try {
- const regex = new RegExp(pattern, flags);
-
- if (!testString) {
- return {
- success: true,
- data: { valid: true, pattern: regex.source, flags: regex.flags },
- };
- }
-
- const matches = [];
- let match;
-
- if (flags.includes("g")) {
- while ((match = regex.exec(testString)) !== null) {
- matches.push({
- match: match[0],
- index: match.index,
- groups: match.groups || {},
- captures: match.slice(1),
- });
- if (!flags.includes("g")) break;
- }
- } else {
- match = regex.exec(testString);
- if (match) {
- matches.push({
- match: match[0],
- index: match.index,
- groups: match.groups || {},
- captures: match.slice(1),
- });
- }
- }
-
- return {
- success: true,
- data: {
- valid: true,
- pattern: regex.source,
- flags: regex.flags,
- matches,
- matchCount: matches.length,
- },
- };
- } catch (error) {
- return {
- success: false,
- error: {
- code: "REGEX_ERROR",
- message: error instanceof Error ? error.message : String(error),
- },
- };
- }
-}
-
-// The worker API exposed to the main thread
-const workerApi = {
- processLargeText,
- hashString,
- processJson,
- validateRegex,
-
- // Utility: Check if worker is responsive
- ping: () => "pong",
-
- // Get worker capabilities
- getCapabilities: () => ({
- subtleCrypto: typeof crypto?.subtle !== "undefined",
- textEncoder: typeof TextEncoder !== "undefined",
- performance: typeof performance !== "undefined",
- }),
-};
-
-// Expose the API via Comlink
-expose(workerApi);
diff --git a/apps/web/styles/accessibility.css b/apps/web/styles/accessibility.css
index d72dcb7..00e9e23 100644
--- a/apps/web/styles/accessibility.css
+++ b/apps/web/styles/accessibility.css
@@ -20,11 +20,6 @@
.border {
border-width: 2px;
}
-
- /* Ensure text has sufficient contrast */
- .text-muted-foreground {
- opacity: 0.8;
- }
}
/* Reduced motion support */
diff --git a/packages/tools/package.json b/packages/tools/package.json
index 3d552a7..6f5b311 100644
--- a/packages/tools/package.json
+++ b/packages/tools/package.json
@@ -46,9 +46,9 @@
"fast-xml-parser": "5.5.9",
"hjson": "3.2.2",
"ini": "4.1.0",
- "isomorphic-dompurify": "3.0.0",
+ "isomorphic-dompurify": "3.9.0",
"jmespath": "0.16.0",
- "js-yaml": "4.1.0",
+ "js-yaml": "4.1.1",
"json5": "2.2.3",
"marked": "17.0.0",
"papaparse": "5.4.0",
diff --git a/packages/tools/scripts/generate-tool.ts b/packages/tools/scripts/generate-tool.ts
index 9a88cd2..220f86c 100644
--- a/packages/tools/scripts/generate-tool.ts
+++ b/packages/tools/scripts/generate-tool.ts
@@ -210,8 +210,8 @@ function generateTestFile(options: ToolOptions): string {
const optionsTest = noOptions
? ""
: `
- it("should handle options", () => {
- const result = executeTool(
+ it("should handle options", async () => {
+ const result = await executeTool(
${toolName},
{ input: "test" },
{ /* options */ }
@@ -227,15 +227,15 @@ import { executeTool } from "../../../src/core/executor";
describe("${toolName}", () => {
describe("execute", () => {
- it("should process valid input", () => {
- const result = executeTool(${toolName}, { input: "test" });
+ it("should process valid input", async () => {
+ const result = await executeTool(${toolName}, { input: "test" });
expect(result.success).toBe(true);
expect(result.data?.output).toBeDefined();
});
${optionsTest}
- it("should return error for empty input", () => {
- const result = executeTool(${toolName}, { input: "" });
+ it("should return error for empty input", async () => {
+ const result = await executeTool(${toolName}, { input: "" });
// Adjust based on tool behavior
expect(result.success).toBe(true);
diff --git a/packages/tools/src/core/execution-meta.ts b/packages/tools/src/core/execution-meta.ts
index 7d934e3..e0a7bde 100644
--- a/packages/tools/src/core/execution-meta.ts
+++ b/packages/tools/src/core/execution-meta.ts
@@ -72,11 +72,15 @@ export function getByteSize(value: unknown): number {
return new TextEncoder().encode(value).length;
}
- // For objects, stringify and measure
+ // For objects, stringify and measure. If the value cannot be serialized
+ // (circular reference, BigInt, etc.) or is so large that stringification
+ // itself would blow memory, fail safe by reporting Infinity so the caller's
+ // 5 MB input-size guard rejects the input rather than silently admitting it.
try {
const str = JSON.stringify(value);
+ if (str === undefined) return Number.POSITIVE_INFINITY;
return new TextEncoder().encode(str).length;
} catch {
- return 0;
+ return Number.POSITIVE_INFINITY;
}
}
diff --git a/packages/tools/src/core/executor.ts b/packages/tools/src/core/executor.ts
index 088a5b2..bd46e41 100644
--- a/packages/tools/src/core/executor.ts
+++ b/packages/tools/src/core/executor.ts
@@ -85,12 +85,16 @@ export async function executeTool(
const startTime = performance.now();
const inputSizeBytes = getByteSize(input);
- // Validate input
- const inputResult = validateInput(tool.inputSchema, input);
- if (!inputResult.success) {
+ // Pre-execution input size check before any validation to prevent
+ // Zod from allocating/parsing pathologically-large inputs.
+ const MAX_INPUT_SIZE_BYTES = 5 * 1024 * 1024;
+ if (inputSizeBytes > MAX_INPUT_SIZE_BYTES) {
return {
success: false,
- error: inputResult.error,
+ error: createToolError({
+ code: EXEC_FAILED,
+ message: `Input too large (${(inputSizeBytes / 1024 / 1024).toFixed(1)}MB). Maximum allowed: 5MB.`,
+ }),
meta: createExecutionMeta({
startTime,
endTime: performance.now(),
@@ -102,12 +106,12 @@ export async function executeTool(
};
}
- // Validate options if schema exists
- const optionsResult = validateOptions(tool.optionsSchema, options);
- if (!optionsResult.success) {
+ // Validate input
+ const inputResult = validateInput(tool.inputSchema, input);
+ if (!inputResult.success) {
return {
success: false,
- error: optionsResult.error,
+ error: inputResult.error,
meta: createExecutionMeta({
startTime,
endTime: performance.now(),
@@ -119,16 +123,12 @@ export async function executeTool(
};
}
- // Pre-execution input size check to prevent memory exhaustion
- // Reject inputs larger than 5MB before tool execution
- const MAX_INPUT_SIZE_BYTES = 5 * 1024 * 1024;
- if (inputSizeBytes > MAX_INPUT_SIZE_BYTES) {
+ // Validate options if schema exists
+ const optionsResult = validateOptions(tool.optionsSchema, options);
+ if (!optionsResult.success) {
return {
success: false,
- error: createToolError({
- code: EXEC_FAILED,
- message: `Input too large (${(inputSizeBytes / 1024 / 1024).toFixed(1)}MB). Maximum allowed: 5MB.`,
- }),
+ error: optionsResult.error,
meta: createExecutionMeta({
startTime,
endTime: performance.now(),
diff --git a/packages/tools/src/tools/color/random-color.ts b/packages/tools/src/tools/color/random-color.ts
index 587f844..b9d9a58 100644
--- a/packages/tools/src/tools/color/random-color.ts
+++ b/packages/tools/src/tools/color/random-color.ts
@@ -51,10 +51,12 @@ export const randomColor = defineTool({
const count = input.count ?? 5;
const format = input.format ?? "hex";
const colors: string[] = [];
+ const buf = new Uint8Array(3);
for (let i = 0; i < count; i++) {
- const r = Math.floor(Math.random() * 256);
- const g = Math.floor(Math.random() * 256);
- const b = Math.floor(Math.random() * 256);
+ crypto.getRandomValues(buf);
+ const r = buf[0]!;
+ const g = buf[1]!;
+ const b = buf[2]!;
switch (format) {
case "hex":
colors.push(rgbToHex({ r, g, b }));
diff --git a/packages/tools/src/tools/crypto/aes-decrypt.ts b/packages/tools/src/tools/crypto/aes-decrypt.ts
index 117bad3..10d069a 100644
--- a/packages/tools/src/tools/crypto/aes-decrypt.ts
+++ b/packages/tools/src/tools/crypto/aes-decrypt.ts
@@ -98,7 +98,7 @@ export const aesDecrypt = defineTool({
{
name: "PBKDF2",
salt,
- iterations: 100000,
+ iterations: 600000,
hash: "SHA-256",
},
passwordKey,
diff --git a/packages/tools/src/tools/crypto/aes-encrypt.ts b/packages/tools/src/tools/crypto/aes-encrypt.ts
index 8aa7504..a34e63b 100644
--- a/packages/tools/src/tools/crypto/aes-encrypt.ts
+++ b/packages/tools/src/tools/crypto/aes-encrypt.ts
@@ -73,7 +73,7 @@ export const aesEncrypt = defineTool({
{
name: "PBKDF2",
salt,
- iterations: 100000,
+ iterations: 600000,
hash: "SHA-256",
},
passwordKey,
diff --git a/packages/tools/src/tools/html/playground.ts b/packages/tools/src/tools/html/playground.ts
index efaf83d..5d01cb2 100644
--- a/packages/tools/src/tools/html/playground.ts
+++ b/packages/tools/src/tools/html/playground.ts
@@ -134,6 +134,7 @@ export const htmlPlayground = defineTool({
ui: {
inputLanguage: "html",
outputRenderer: "html",
+ htmlPreviewAllowScripts: true,
},
},
inputSchema,
diff --git a/packages/tools/src/tools/jwt/fernet-encoder.ts b/packages/tools/src/tools/jwt/fernet-encoder.ts
index 7218ef8..ea6df35 100644
--- a/packages/tools/src/tools/jwt/fernet-encoder.ts
+++ b/packages/tools/src/tools/jwt/fernet-encoder.ts
@@ -7,7 +7,7 @@ function base64UrlEncode(data: Uint8Array): string {
for (let i = 0; i < data.length; i++) {
binary += String.fromCharCode(data[i]!);
}
- return btoa(binary);
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_");
}
const inputSchema = z.object({
diff --git a/packages/tools/src/tools/jwt/jwt-decoder.ts b/packages/tools/src/tools/jwt/jwt-decoder.ts
index 55fc586..2dfda21 100644
--- a/packages/tools/src/tools/jwt/jwt-decoder.ts
+++ b/packages/tools/src/tools/jwt/jwt-decoder.ts
@@ -71,7 +71,7 @@ export const jwtDecoder = defineTool({
id: "jwt/jwt-decoder",
name: "JWT Decoder",
description:
- "Free online JWT decoder — decode JWT tokens into header, payload, and signature instantly in your browser. No data is stored. Parses base64url-encoded header and payload claims, displays the raw signature.",
+ "Free online JWT decoder — decode JWT tokens into header, payload, and signature instantly in your browser. No data is stored. Parses base64url-encoded header and payload claims, displays the raw signature. Note: signatures are NOT verified — use the JWT Debugger to check expiry and algorithm.",
category: "jwt",
tier: ToolTier.CLIENT,
keywords: [
diff --git a/packages/tools/src/tools/math/random-number-generator.ts b/packages/tools/src/tools/math/random-number-generator.ts
index 93e90c1..6927a1b 100644
--- a/packages/tools/src/tools/math/random-number-generator.ts
+++ b/packages/tools/src/tools/math/random-number-generator.ts
@@ -45,10 +45,35 @@ export const randomNumberGenerator = defineTool({
const count = input.count ?? 1;
const integers = input.integers ?? true;
if (min > max) throw new Error("Min must be <= max");
+
+ // Use crypto.getRandomValues for unbiased sampling. Math.random() with
+ // Math.round() has well-known boundary bias (values exactly at min/max
+ // appear at half the expected rate) which users of a "Random Number
+ // Generator" tool reasonably do not expect.
const nums: number[] = [];
- for (let i = 0; i < count; i++) {
- const r = Math.random() * (max - min) + min;
- nums.push(integers ? Math.round(r) : parseFloat(r.toFixed(6)));
+ if (integers) {
+ const lo = Math.ceil(min);
+ const hi = Math.floor(max);
+ const range = hi - lo + 1;
+ if (range <= 0) throw new Error("No integers in the given range");
+ // Rejection sampling over a uint32 window to avoid modulo bias.
+ const maxUnbiased = Math.floor(0x1_0000_0000 / range) * range;
+ const buf = new Uint32Array(1);
+ for (let i = 0; i < count; i++) {
+ let r: number;
+ do {
+ crypto.getRandomValues(buf);
+ r = buf[0]!;
+ } while (r >= maxUnbiased);
+ nums.push(lo + (r % range));
+ }
+ } else {
+ const buf = new Uint32Array(1);
+ for (let i = 0; i < count; i++) {
+ crypto.getRandomValues(buf);
+ const frac = buf[0]! / 0x1_0000_0000;
+ nums.push(parseFloat((frac * (max - min) + min).toFixed(6)));
+ }
}
return { output: nums.join(", ") };
},
diff --git a/packages/tools/src/tools/svg/svg-viewer.ts b/packages/tools/src/tools/svg/svg-viewer.ts
index bfe64b6..95f8e53 100644
--- a/packages/tools/src/tools/svg/svg-viewer.ts
+++ b/packages/tools/src/tools/svg/svg-viewer.ts
@@ -1,3 +1,4 @@
+import DOMPurify from "isomorphic-dompurify";
import { z } from "zod";
import { defineTool } from "../../core/define-tool";
import { ToolTier } from "../../types";
@@ -13,60 +14,16 @@ const outputSchema = z.object({
type Input = z.infer;
type Output = z.infer;
-// Dangerous SVG elements that can execute code
-const DANGEROUS_ELEMENTS = [
- "script",
- "foreignobject",
- "iframe",
- "object",
- "embed",
- "applet",
- "form",
- "input",
- "textarea",
- "select",
- "button",
-];
-
-// Event handler attributes (on*)
-const EVENT_HANDLER_RE = /\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi;
-
-// Dangerous attribute values (javascript:, data:, vbscript:)
-const DANGEROUS_ATTR_RE =
- /\s+(href|xlink:href|src|action|formaction)\s*=\s*(?:"(?:javascript|data|vbscript):[^"]*"|'(?:javascript|data|vbscript):[^']*')/gi;
-
-//