@webcrawl/client 0.0.0-stage → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,48 @@
1
- # Temporary Holding Version
1
+ # @webcrawl/client
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Zero-dependency TypeScript & Node.js SDK for **[Webcrawl Anything](https://github.com/brainfish-ai/webcrawl-anything)** and **[webcrawl.app](https://webcrawl.app)**.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @webcrawl/client
9
+ ```
10
+
11
+ ## Quickstart
12
+
13
+ ```ts
14
+ import { WebcrawlClient } from "@webcrawl/client";
15
+
16
+ const client = new WebcrawlClient({
17
+ apiKey: process.env.WEBCRAWL_API_KEY, // or run `npx webcrawl login`
18
+ });
19
+
20
+ // 1. Search the web or structured verticals (shopping, flights, hotels, real-estate, security CVEs)
21
+ const results = await client.search({
22
+ query: "CVE-2024-3094 xz utils",
23
+ category: "security",
24
+ subcategory: "cve",
25
+ limit: 5,
26
+ });
27
+
28
+ // 2. Scrape any URL into clean Markdown
29
+ const page = await client.scrape({
30
+ url: "https://github.com/brainfish-ai/webcrawl-anything",
31
+ formats: ["markdown"],
32
+ });
33
+
34
+ // 3. Parse PDFs, Office files (.docx, .xlsx, .pptx), or YouTube transcripts
35
+ const doc = await client.parse({
36
+ url: "https://arxiv.org/pdf/1706.03762.pdf",
37
+ ocr: "auto",
38
+ });
39
+
40
+ // 4. List all supported crawl & search targets
41
+ const targets = await client.targets();
42
+ ```
43
+
44
+ ## Documentation & Source
45
+
46
+ - **GitHub Repository:** [brainfish-ai/webcrawl-anything](https://github.com/brainfish-ai/webcrawl-anything)
47
+ - **Cloud Platform:** [https://webcrawl.app](https://webcrawl.app)
48
+ - **License:** MIT
@@ -0,0 +1,35 @@
1
+ import type { ApiResponse, CrawlParams, ExtractParams, MapParams, ParseParams, ParseResponse, RerankParams, ScrapeParams, SearchParams, SearchResponse } from "./types.js";
2
+ export interface ClientOptions {
3
+ apiKey?: string;
4
+ baseUrl?: string;
5
+ /** Per-request timeout in ms. Default 60s. */
6
+ timeoutMs?: number;
7
+ fetch?: typeof fetch;
8
+ userAgent?: string;
9
+ /** IANA timezone sent as `X-User-Timezone` so results are local. Defaults to this machine's. */
10
+ timezone?: string;
11
+ }
12
+ export declare class WebcrawlClient {
13
+ readonly baseUrl: string;
14
+ private readonly apiKey?;
15
+ private readonly timeoutMs;
16
+ private readonly fetchImpl;
17
+ private readonly userAgent;
18
+ private readonly timezone?;
19
+ constructor(opts?: ClientOptions);
20
+ request<T = ApiResponse>(method: "GET" | "POST", path: string, body?: unknown): Promise<T>;
21
+ search(p: SearchParams): Promise<SearchResponse>;
22
+ scrape(p: ScrapeParams): Promise<ApiResponse>;
23
+ /** PDF, DOCX/XLSX/PPTX, image or audio/video to Markdown. Long files: pass `async: true` and poll `run()`. */
24
+ parse(p: ParseParams): Promise<ParseResponse>;
25
+ /** Status, trace and (when finished) result of an async run. */
26
+ run(id: string): Promise<ApiResponse>;
27
+ map(p: MapParams): Promise<ApiResponse>;
28
+ crawl(p: CrawlParams): Promise<ApiResponse>;
29
+ extract(p: ExtractParams): Promise<ApiResponse>;
30
+ rerank(p: RerankParams): Promise<ApiResponse>;
31
+ categories(): Promise<ApiResponse>;
32
+ targets(): Promise<ApiResponse>;
33
+ usage(): Promise<ApiResponse>;
34
+ }
35
+ export declare function createClient(opts?: ClientOptions): WebcrawlClient;
package/dist/client.js ADDED
@@ -0,0 +1,130 @@
1
+ import { DEFAULT_BASE_URL } from "./config.js";
2
+ import { WebcrawlError, kindForStatus } from "./errors.js";
3
+ function localTimezone() {
4
+ try {
5
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
6
+ }
7
+ catch {
8
+ return undefined;
9
+ }
10
+ }
11
+ function bytesToBase64(bytes) {
12
+ if (typeof Buffer !== "undefined") {
13
+ return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64");
14
+ }
15
+ let s = "";
16
+ for (let i = 0; i < bytes.length; i += 0x8000) {
17
+ s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
18
+ }
19
+ return btoa(s);
20
+ }
21
+ const compact = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
22
+ export class WebcrawlClient {
23
+ baseUrl;
24
+ apiKey;
25
+ timeoutMs;
26
+ fetchImpl;
27
+ userAgent;
28
+ timezone;
29
+ constructor(opts = {}) {
30
+ this.baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
31
+ this.apiKey = opts.apiKey;
32
+ this.timeoutMs = opts.timeoutMs ?? 60_000;
33
+ this.fetchImpl = opts.fetch ?? globalThis.fetch;
34
+ this.userAgent = opts.userAgent ?? "webcrawl-client/0.1";
35
+ this.timezone = opts.timezone ?? localTimezone();
36
+ }
37
+ async request(method, path, body) {
38
+ const headers = {
39
+ accept: "application/json",
40
+ "user-agent": this.userAgent,
41
+ };
42
+ if (this.apiKey) {
43
+ headers.authorization = `Bearer ${this.apiKey}`;
44
+ }
45
+ if (this.timezone) {
46
+ headers["x-user-timezone"] = this.timezone;
47
+ }
48
+ if (body !== undefined) {
49
+ headers["content-type"] = "application/json";
50
+ }
51
+ let res;
52
+ try {
53
+ res = await this.fetchImpl(`${this.baseUrl}${path}`, {
54
+ method,
55
+ headers,
56
+ body: body === undefined ? undefined : JSON.stringify(body),
57
+ signal: AbortSignal.timeout(this.timeoutMs),
58
+ });
59
+ }
60
+ catch (e) {
61
+ const name = e instanceof Error ? e.name : "";
62
+ if (name === "TimeoutError" || name === "AbortError") {
63
+ throw new WebcrawlError("timeout", `Request to ${path} timed out after ${this.timeoutMs}ms`);
64
+ }
65
+ throw new WebcrawlError("network", e instanceof Error ? e.message : String(e));
66
+ }
67
+ const text = await res.text();
68
+ let json;
69
+ try {
70
+ json = text ? JSON.parse(text) : {};
71
+ }
72
+ catch {
73
+ json = { error: text.slice(0, 300) };
74
+ }
75
+ if (!res.ok) {
76
+ const obj = (json && typeof json === "object" ? json : {});
77
+ const retry = Number(res.headers.get("retry-after"));
78
+ throw new WebcrawlError(kindForStatus(res.status), typeof obj.error === "string" ? obj.error : `HTTP ${res.status}`, { status: res.status, retryAfter: Number.isFinite(retry) && retry > 0 ? retry : undefined, body: json });
79
+ }
80
+ return json;
81
+ }
82
+ search(p) {
83
+ const { sites, scrapeContent, ...rest } = p;
84
+ return this.request("POST", "/v1/search", {
85
+ ...compact(rest),
86
+ ...(sites?.length ? { filters: { sites } } : {}),
87
+ ...(scrapeContent ? { scrapeOptions: { formats: ["markdown"] } } : {}),
88
+ });
89
+ }
90
+ scrape(p) {
91
+ return this.request("POST", "/v1/scrape", compact({ url: p.url, formats: p.formats, noCache: p.noCache }));
92
+ }
93
+ /** PDF, DOCX/XLSX/PPTX, image or audio/video to Markdown. Long files: pass `async: true` and poll `run()`. */
94
+ parse(p) {
95
+ const { bytes, noCache, ...rest } = p;
96
+ if (!p.url && !bytes && !p.base64) {
97
+ throw new WebcrawlError("bad_request", "parse needs `url`, `bytes` or `base64`");
98
+ }
99
+ const base64 = bytes ? bytesToBase64(bytes) : p.base64;
100
+ return this.request("POST", "/v1/parse", compact({ ...rest, base64, ...(noCache ? { noCache: true } : {}) }));
101
+ }
102
+ /** Status, trace and (when finished) result of an async run. */
103
+ run(id) {
104
+ return this.request("GET", `/v1/runs/${encodeURIComponent(id)}`);
105
+ }
106
+ map(p) {
107
+ return this.request("POST", "/v1/map", compact({ ...p }));
108
+ }
109
+ crawl(p) {
110
+ return this.request("POST", "/v1/crawl", compact({ ...p, maxPages: p.maxPages === undefined ? undefined : Math.min(25, Math.max(1, p.maxPages)) }));
111
+ }
112
+ extract(p) {
113
+ return this.request("POST", "/v1/extract", compact({ ...p }));
114
+ }
115
+ rerank(p) {
116
+ return this.request("POST", "/v1/rerank", compact({ ...p }));
117
+ }
118
+ categories() {
119
+ return this.request("GET", "/v1/search/categories");
120
+ }
121
+ targets() {
122
+ return this.request("GET", "/v1/targets");
123
+ }
124
+ usage() {
125
+ return this.request("GET", "/v1/account/usage");
126
+ }
127
+ }
128
+ export function createClient(opts = {}) {
129
+ return new WebcrawlClient(opts);
130
+ }
@@ -0,0 +1,27 @@
1
+ export declare const DEFAULT_BASE_URL = "https://webcrawl.app";
2
+ export type ExecutionMode = "local" | "cloud" | "auto";
3
+ export interface FileConfig {
4
+ apiKey?: string;
5
+ baseUrl?: string;
6
+ mode?: ExecutionMode;
7
+ }
8
+ export interface ResolvedConfig {
9
+ apiKey?: string;
10
+ baseUrl: string;
11
+ mode: ExecutionMode;
12
+ effectiveMode: "local" | "cloud";
13
+ source: {
14
+ apiKey: "flag" | "env" | "file" | "none";
15
+ baseUrl: "flag" | "env" | "file" | "default";
16
+ mode: "flag" | "env" | "file" | "default";
17
+ };
18
+ }
19
+ export declare function configPath(env?: NodeJS.ProcessEnv): string;
20
+ export declare function readConfigFile(path?: string): FileConfig;
21
+ export declare function writeConfigFile(cfg: FileConfig, path?: string): void;
22
+ /** Precedence: explicit flags > environment > config file > defaults. */
23
+ export declare function resolveConfig(flags?: {
24
+ apiKey?: string;
25
+ baseUrl?: string;
26
+ mode?: string;
27
+ }, env?: NodeJS.ProcessEnv, file?: FileConfig): ResolvedConfig;
package/dist/config.js ADDED
@@ -0,0 +1,58 @@
1
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ export const DEFAULT_BASE_URL = "https://webcrawl.app";
5
+ export function configPath(env = process.env) {
6
+ const base = env.XDG_CONFIG_HOME || join(homedir(), ".config");
7
+ return join(base, "webcrawl", "config.json");
8
+ }
9
+ export function readConfigFile(path = configPath()) {
10
+ try {
11
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
12
+ if (parsed && typeof parsed === "object") {
13
+ const o = parsed;
14
+ return {
15
+ apiKey: typeof o.apiKey === "string" ? o.apiKey : undefined,
16
+ baseUrl: typeof o.baseUrl === "string" ? o.baseUrl : undefined,
17
+ mode: o.mode === "local" || o.mode === "cloud" || o.mode === "auto" ? o.mode : undefined,
18
+ };
19
+ }
20
+ }
21
+ catch {
22
+ // missing or unreadable config is fine
23
+ }
24
+ return {};
25
+ }
26
+ export function writeConfigFile(cfg, path = configPath()) {
27
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
28
+ writeFileSync(path, `${JSON.stringify(cfg, null, 2)}\n`, { mode: 0o600 });
29
+ chmodSync(path, 0o600);
30
+ }
31
+ const clean = (v) => (v && v.trim() ? v.trim() : undefined);
32
+ /** Precedence: explicit flags > environment > config file > defaults. */
33
+ export function resolveConfig(flags = {}, env = process.env, file = readConfigFile(configPath(env))) {
34
+ const pick = (f, e, fl, none) => {
35
+ if (clean(f)) {
36
+ return [clean(f), "flag"];
37
+ }
38
+ if (clean(e)) {
39
+ return [clean(e), "env"];
40
+ }
41
+ if (clean(fl)) {
42
+ return [clean(fl), "file"];
43
+ }
44
+ return [undefined, none];
45
+ };
46
+ const [apiKey, keySrc] = pick(flags.apiKey, env.WEBCRAWL_API_KEY, file.apiKey, "none");
47
+ const [baseUrl, urlSrc] = pick(flags.baseUrl, env.WEBCRAWL_URL, file.baseUrl, "default");
48
+ const [rawMode, modeSrc] = pick(flags.mode, env.WEBCRAWL_MODE, file.mode, "default");
49
+ const mode = rawMode === "local" || rawMode === "cloud" || rawMode === "auto" ? rawMode : "auto";
50
+ const effectiveMode = mode === "local" ? "local" : mode === "cloud" ? "cloud" : apiKey ? "cloud" : "local";
51
+ return {
52
+ apiKey,
53
+ baseUrl: (baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ""),
54
+ mode,
55
+ effectiveMode,
56
+ source: { apiKey: keySrc, baseUrl: urlSrc, mode: modeSrc },
57
+ };
58
+ }
@@ -0,0 +1,16 @@
1
+ export type WebcrawlErrorKind = "auth" | "credits" | "rate_limit" | "bad_request" | "not_found" | "server" | "network" | "timeout";
2
+ export declare class WebcrawlError extends Error {
3
+ readonly kind: WebcrawlErrorKind;
4
+ readonly status?: number;
5
+ /** Seconds to wait before retrying, from `Retry-After` on 429. */
6
+ readonly retryAfter?: number;
7
+ readonly body?: unknown;
8
+ constructor(kind: WebcrawlErrorKind, message: string, opts?: {
9
+ status?: number;
10
+ retryAfter?: number;
11
+ body?: unknown;
12
+ });
13
+ }
14
+ export declare function kindForStatus(status: number): WebcrawlErrorKind;
15
+ /** One actionable sentence for people (and models) reading the error. */
16
+ export declare function describeError(err: unknown, baseUrl?: string): string;
package/dist/errors.js ADDED
@@ -0,0 +1,55 @@
1
+ export class WebcrawlError extends Error {
2
+ kind;
3
+ status;
4
+ /** Seconds to wait before retrying, from `Retry-After` on 429. */
5
+ retryAfter;
6
+ body;
7
+ constructor(kind, message, opts = {}) {
8
+ super(message);
9
+ this.name = "WebcrawlError";
10
+ this.kind = kind;
11
+ this.status = opts.status;
12
+ this.retryAfter = opts.retryAfter;
13
+ this.body = opts.body;
14
+ }
15
+ }
16
+ export function kindForStatus(status) {
17
+ if (status === 401 || status === 403) {
18
+ return "auth";
19
+ }
20
+ if (status === 402) {
21
+ return "credits";
22
+ }
23
+ if (status === 429) {
24
+ return "rate_limit";
25
+ }
26
+ if (status === 404) {
27
+ return "not_found";
28
+ }
29
+ if (status >= 500) {
30
+ return "server";
31
+ }
32
+ return "bad_request";
33
+ }
34
+ /** One actionable sentence for people (and models) reading the error. */
35
+ export function describeError(err, baseUrl = "https://webcrawl.app") {
36
+ if (!(err instanceof WebcrawlError)) {
37
+ return err instanceof Error ? err.message : String(err);
38
+ }
39
+ switch (err.kind) {
40
+ case "auth":
41
+ return "Your API key is missing or invalid. Set WEBCRAWL_API_KEY or run `webcrawl login`.";
42
+ case "credits":
43
+ return `You are out of credits. Add more at ${baseUrl}/account.`;
44
+ case "rate_limit":
45
+ return err.retryAfter
46
+ ? `Rate limited. Retry in ${err.retryAfter}s.`
47
+ : "Rate limited. Wait a moment and retry.";
48
+ case "timeout":
49
+ return "The request timed out. Try again, or narrow the request.";
50
+ case "network":
51
+ return `Could not reach ${baseUrl}. Check your connection or WEBCRAWL_URL.`;
52
+ default:
53
+ return err.message;
54
+ }
55
+ }
@@ -0,0 +1,4 @@
1
+ export { WebcrawlClient, createClient, type ClientOptions } from "./client.js";
2
+ export { DEFAULT_BASE_URL, configPath, readConfigFile, resolveConfig, writeConfigFile, type ExecutionMode, type FileConfig, type ResolvedConfig, } from "./config.js";
3
+ export { WebcrawlError, describeError, kindForStatus, type WebcrawlErrorKind } from "./errors.js";
4
+ export type * from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { WebcrawlClient, createClient } from "./client.js";
2
+ export { DEFAULT_BASE_URL, configPath, readConfigFile, resolveConfig, writeConfigFile, } from "./config.js";
3
+ export { WebcrawlError, describeError, kindForStatus } from "./errors.js";
@@ -0,0 +1,126 @@
1
+ /** Fields every API response may carry. Responses are intentionally loose: the API grows faster than this client. */
2
+ export interface CreditInfo {
3
+ charged?: number;
4
+ used?: number;
5
+ remaining?: number;
6
+ limit?: number;
7
+ }
8
+ export interface ApiResponse {
9
+ success?: boolean;
10
+ credits?: CreditInfo;
11
+ [key: string]: unknown;
12
+ }
13
+ export interface Location {
14
+ country?: string;
15
+ region?: string;
16
+ city?: string;
17
+ timezone?: string;
18
+ }
19
+ export interface SearchParams extends Location {
20
+ query: string;
21
+ category?: string;
22
+ subcategory?: string;
23
+ limit?: number;
24
+ /** Restrict to these domains. */
25
+ sites?: string[];
26
+ includeDomains?: string[];
27
+ excludeDomains?: string[];
28
+ /** Time filter, e.g. `qdr:w`. */
29
+ tbs?: string;
30
+ scrapeContent?: boolean;
31
+ }
32
+ export interface SearchResponse extends ApiResponse {
33
+ results?: Array<{
34
+ title?: string;
35
+ url?: string;
36
+ description?: string;
37
+ markdown?: string;
38
+ }>;
39
+ data?: unknown;
40
+ offers?: unknown[];
41
+ products?: unknown[];
42
+ listings?: unknown[];
43
+ flights?: unknown[];
44
+ hotels?: unknown[];
45
+ businesses?: unknown[];
46
+ /** Structured CVE records (category "security"): id, cvssScore, severity, affected, fixed versions, kev, references. */
47
+ cves?: unknown[];
48
+ }
49
+ export interface ScrapeParams {
50
+ url: string;
51
+ formats?: Array<"markdown" | "links" | "html">;
52
+ noCache?: boolean;
53
+ }
54
+ /** Parse a file (PDF, Office, image, audio/video) from a URL, raw bytes or base64. */
55
+ export interface ParseParams {
56
+ /** File URL. Provide one of `url`, `bytes` or `base64`. */
57
+ url?: string;
58
+ /** Raw file bytes (sent as base64 JSON). */
59
+ bytes?: Uint8Array;
60
+ base64?: string;
61
+ fileName?: string;
62
+ contentType?: string;
63
+ /** `auto` OCRs only scanned pages; `force` OCRs every page; `off` never OCRs. */
64
+ ocr?: "auto" | "force" | "off";
65
+ /** 1-indexed PDF pages to read. */
66
+ pages?: number[];
67
+ maxOcrPages?: number;
68
+ /** ISO-639-1 hint for transcription. */
69
+ language?: string;
70
+ /** WebVTT/SRT captions to use instead of transcribing. */
71
+ captions?: string;
72
+ describeVideo?: boolean;
73
+ /** Run as a durable job (returns a run id), for big PDFs and long media. */
74
+ async?: boolean;
75
+ noCache?: boolean;
76
+ }
77
+ export interface ParseMetadata {
78
+ kind?: string;
79
+ method?: "native" | "ocr" | "vision" | "transcript";
80
+ pages?: number;
81
+ pagesOcr?: number[];
82
+ pagesSkipped?: Array<{
83
+ page: number;
84
+ reason: string;
85
+ }>;
86
+ durationSeconds?: number;
87
+ warnings?: string[];
88
+ }
89
+ export interface ParseResponse extends ApiResponse {
90
+ title?: string;
91
+ markdown?: string;
92
+ /** Present when the parse runs as a durable job. */
93
+ id?: string;
94
+ status?: string;
95
+ statusUrl?: string;
96
+ metadata?: {
97
+ parse?: ParseMetadata;
98
+ [key: string]: unknown;
99
+ };
100
+ }
101
+ export interface MapParams {
102
+ url: string;
103
+ limit?: number;
104
+ search?: string;
105
+ }
106
+ export interface CrawlParams {
107
+ url: string;
108
+ maxPages?: number;
109
+ extractStructured?: boolean;
110
+ }
111
+ export interface ExtractParams {
112
+ url: string;
113
+ prompt?: string;
114
+ schema?: Record<string, unknown>;
115
+ }
116
+ export type RerankDocument = string | {
117
+ title?: string;
118
+ url?: string;
119
+ text?: string;
120
+ description?: string;
121
+ };
122
+ export interface RerankParams {
123
+ query: string;
124
+ documents: RerankDocument[];
125
+ topK?: number;
126
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "@webcrawl/client",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.2.0",
4
+ "description": "Zero-dependency TypeScript & Node.js SDK for Webcrawl Anything (search, scrape, parse, map, crawl, extract, rerank, targets)",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Brainfish AI <support@webcrawl.app> (https://webcrawl.app)",
8
+ "homepage": "https://webcrawl.app",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/brainfish-ai/webcrawl-anything.git",
12
+ "directory": "packages/client"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/brainfish-ai/webcrawl-anything/issues"
16
+ },
17
+ "keywords": [
18
+ "webcrawl",
19
+ "web-crawler",
20
+ "web-scraper",
21
+ "search-api",
22
+ "markdown",
23
+ "pdf-parser",
24
+ "cve",
25
+ "mcp",
26
+ "ai-agents"
27
+ ],
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "files": [
32
+ "dist",
33
+ "README.md"
34
+ ],
35
+ "main": "./dist/index.js",
36
+ "types": "./dist/index.d.ts",
37
+ "exports": {
38
+ ".": {
39
+ "development": "./src/index.ts",
40
+ "types": "./dist/index.d.ts",
41
+ "default": "./dist/index.js"
42
+ }
43
+ },
44
+ "scripts": {
45
+ "build": "tsc -p tsconfig.build.json",
46
+ "prepack": "npm run build",
47
+ "test": "node --conditions=development --import tsx --test test/*.test.ts"
48
+ },
49
+ "engines": {
50
+ "node": ">=20"
51
+ }
52
+ }