zenrows 2.0.3 → 2.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/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import fetchRetry from "fetch-retry";
5
5
  // package.json
6
6
  var package_default = {
7
7
  name: "zenrows",
8
- version: "2.0.2",
8
+ version: "2.2.0",
9
9
  description: "ZenRows Node SDK",
10
10
  main: "./dist/index.cjs",
11
11
  module: "./dist/index.js",
@@ -24,10 +24,19 @@ var package_default = {
24
24
  scripts: {
25
25
  build: "tsup src/index.ts --dts --format cjs,esm --clean",
26
26
  test: "vitest",
27
+ "test:e2e": "vitest run tests/e2e",
27
28
  format: "biome format --write .",
28
29
  check: "biome check ."
29
30
  },
30
- keywords: ["sdk", "zenrows", "scraping"],
31
+ keywords: [
32
+ "sdk",
33
+ "zenrows",
34
+ "scraping",
35
+ "proxy",
36
+ "data extraction",
37
+ "web scraping",
38
+ "web scraping api"
39
+ ],
31
40
  author: "ZenRows",
32
41
  repository: {
33
42
  type: "git",
@@ -167,12 +176,13 @@ var ZenRowsBatchError = class extends Error {
167
176
  code;
168
177
  extras;
169
178
  constructor(status, problem, extras) {
170
- const message = problem ? `${status} ${problem.title ?? "Error"}: ${problem.detail ?? problem.code ?? "unknown"}` : `${status} (no problem body)`;
179
+ const code = problem?.code ?? "internal";
180
+ const message = problem ? `${status} ${problem.title ?? "Error"}: ${problem.detail ?? code}` : `${status} (no problem body)`;
171
181
  super(message);
172
182
  this.name = "ZenRowsBatchError";
173
183
  this.status = status;
174
- this.problem = problem;
175
- this.code = problem?.code ?? "internal";
184
+ this.problem = problem && { ...problem, code };
185
+ this.code = code;
176
186
  this.extras = extras;
177
187
  }
178
188
  };
@@ -198,7 +208,7 @@ async function parseProblem(response) {
198
208
  type: typeof record.type === "string" ? record.type : "about:blank",
199
209
  title: typeof record.title === "string" ? record.title : "Error",
200
210
  status: typeof record.status === "number" ? record.status : response.status,
201
- code: typeof record.code === "string" ? record.code : "internal",
211
+ code: typeof record.code === "string" ? record.code : void 0,
202
212
  detail: typeof record.detail === "string" ? record.detail : void 0,
203
213
  instance: typeof record.instance === "string" ? record.instance : void 0
204
214
  };
@@ -621,6 +631,7 @@ var IDEMPOTENT_METHODS = /* @__PURE__ */ new Set(["GET", "PUT", "DELETE", "HEAD"
621
631
  var BACKOFF_BASE_MS = 250;
622
632
  var BACKOFF_CAP_MS = 1e4;
623
633
  var DEFAULT_RETRIES = 3;
634
+ var RETRY_AFTER_CAP_MS = 3e4;
624
635
  function isIdempotent(method, hasIdempotencyKey) {
625
636
  return IDEMPOTENT_METHODS.has(method.toUpperCase()) || method.toUpperCase() === "POST" && hasIdempotencyKey;
626
637
  }
@@ -633,41 +644,71 @@ function retryAfterMs(response) {
633
644
  if (!raw) return void 0;
634
645
  const secs = Number(raw);
635
646
  if (Number.isNaN(secs) || secs < 0) return void 0;
636
- return secs * 1e3;
647
+ return Math.min(secs * 1e3, RETRY_AFTER_CAP_MS);
637
648
  }
638
649
  function sleep(ms) {
639
650
  return new Promise((resolve) => setTimeout(resolve, ms));
640
651
  }
652
+ var batchError = (response, problem, extras) => new ZenRowsBatchError(response.status, problem, extras);
653
+ function deadline(ms) {
654
+ if (ms === void 0) return { clear() {
655
+ } };
656
+ const controller = new AbortController();
657
+ const timer = setTimeout(
658
+ () => controller.abort(new DOMException(`request timed out after ${ms / 1e3}s`, "TimeoutError")),
659
+ ms
660
+ );
661
+ return { signal: controller.signal, clear: () => clearTimeout(timer) };
662
+ }
641
663
  var BatchTransport = class {
642
- constructor(baseURL, apiKey, retries = DEFAULT_RETRIES) {
664
+ constructor(baseURL, apiKey, retries = DEFAULT_RETRIES, errorFactory = batchError, timeoutMs) {
643
665
  this.baseURL = baseURL;
644
666
  this.apiKey = apiKey;
645
667
  this.retries = retries;
668
+ this.errorFactory = errorFactory;
669
+ this.timeoutMs = timeoutMs;
646
670
  }
647
671
  baseURL;
648
672
  apiKey;
649
673
  retries;
650
- /** Send a request, parse the response, throw `ZenRowsBatchError` on non-2xx. */
674
+ errorFactory;
675
+ timeoutMs;
676
+ /** Send a request, parse the response, throw the factory's error on non-2xx. */
651
677
  async requestJson(method, path, options = {}) {
652
- const response = await this.send(method, path, options);
653
- if (!response.ok) {
654
- const { problem, extras } = await parseProblem(response);
655
- throw new ZenRowsBatchError(response.status, problem, extras);
656
- }
657
- const text = await response.text();
678
+ const text = await this.requestText(method, path, options);
658
679
  if (!text) {
659
680
  return void 0;
660
681
  }
661
682
  return JSON.parse(text);
662
683
  }
663
- /** Send a request and return the raw `Response` — used for endpoints that don't return JSON. */
684
+ /** Send a request and return its body as text; throw the factory's error on non-2xx. */
685
+ async requestText(method, path, options = {}) {
686
+ const { response, clear } = await this.send(method, path, options);
687
+ try {
688
+ await this.throwUnlessOk(response);
689
+ return await response.text();
690
+ } finally {
691
+ clear();
692
+ }
693
+ }
694
+ /**
695
+ * Send a request and return the raw `Response`, for bodies read as a stream. The timeout
696
+ * stops once the headers of a 2xx arrive; an error body is still read within it.
697
+ */
664
698
  async requestRaw(method, path, options = {}) {
665
- const response = await this.send(method, path, options);
699
+ const { response, clear } = await this.send(method, path, options);
700
+ try {
701
+ await this.throwUnlessOk(response);
702
+ } finally {
703
+ clear();
704
+ }
705
+ return response;
706
+ }
707
+ async throwUnlessOk(response) {
666
708
  if (!response.ok) {
667
709
  const { problem, extras } = await parseProblem(response);
668
- throw new ZenRowsBatchError(response.status, problem, extras);
710
+ throw this.errorFactory(response, problem, extras);
669
711
  }
670
- return response;
671
712
  }
672
713
  buildUrl(path, query) {
673
714
  const url = new URL(`${this.baseURL}${path}`);
@@ -694,16 +735,20 @@ var BatchTransport = class {
694
735
  headers["Idempotency-Key"] = options.idempotencyKey;
695
736
  }
696
737
  const idempotent = isIdempotent(method, Boolean(options.idempotencyKey));
738
+ const retryStatuses = options.retryStatuses ?? RETRYABLE_STATUSES;
697
739
  let attempt = 0;
698
740
  while (true) {
741
+ const { signal, clear } = deadline(this.timeoutMs);
699
742
  let response;
700
743
  try {
701
744
  response = await fetch(url.toString(), {
702
745
  method,
703
746
  headers,
704
- body: options.body !== void 0 ? JSON.stringify(options.body) : void 0
747
+ body: options.body !== void 0 ? JSON.stringify(options.body) : void 0,
748
+ signal
705
749
  });
706
750
  } catch (error) {
751
+ clear();
707
752
  if (idempotent && attempt < this.retries) {
708
753
  await sleep(backoffMs(attempt));
709
754
  attempt += 1;
@@ -711,13 +756,16 @@ var BatchTransport = class {
711
756
  }
712
757
  throw error;
713
758
  }
714
- if (idempotent && attempt < this.retries && RETRYABLE_STATUSES.has(response.status)) {
759
+ if (idempotent && attempt < this.retries && retryStatuses.has(response.status)) {
760
+ response.body?.cancel().catch(() => {
761
+ });
762
+ clear();
715
763
  const wait = retryAfterMs(response) ?? backoffMs(attempt);
716
764
  await sleep(wait);
717
765
  attempt += 1;
718
766
  continue;
719
767
  }
720
- return response;
768
+ return { response, clear };
721
769
  }
722
770
  }
723
771
  };
@@ -744,7 +792,7 @@ async function pollUntil(fetch2, {
744
792
  backoff = 1.5,
745
793
  jitter = 0.2
746
794
  }) {
747
- const deadline = Date.now() + timeout * 1e3;
795
+ const deadline2 = Date.now() + timeout * 1e3;
748
796
  let interval = initialInterval;
749
797
  while (true) {
750
798
  const value = await fetch2();
@@ -757,10 +805,10 @@ async function pollUntil(fetch2, {
757
805
  );
758
806
  }
759
807
  const now = Date.now();
760
- if (now >= deadline) {
808
+ if (now >= deadline2) {
761
809
  throw new WaiterTimeoutError(`waiter: timed out after ${timeout}s waiting for target state`);
762
810
  }
763
- const remainingSeconds = (deadline - now) / 1e3;
811
+ const remainingSeconds = (deadline2 - now) / 1e3;
764
812
  const sleepSeconds = Math.min(interval, remainingSeconds) * (1 + jitter * (Math.random() * 2 - 1));
765
813
  if (sleepSeconds > 0) {
766
814
  await new Promise((resolve) => setTimeout(resolve, sleepSeconds * 1e3));
@@ -771,7 +819,7 @@ async function pollUntil(fetch2, {
771
819
 
772
820
  // src/batch/client.ts
773
821
  var DEFAULT_BATCH_API_URL = "https://async.api.zenrows.com/v1";
774
- var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set(["completed", "stopped", "deleted"]);
822
+ var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set(["completed", "stopped", "failed", "deleted"]);
775
823
  var TERMINAL_EXPORT_STATUSES = /* @__PURE__ */ new Set(["completed", "failed"]);
776
824
  function coerceUrl(item) {
777
825
  return typeof item === "string" ? { url: item } : item;
@@ -1134,7 +1182,8 @@ var ZenRowsBatchClient = class {
1134
1182
  return this._getRunData(jobId, options.runId);
1135
1183
  };
1136
1184
  return pollUntil(fetchRun, {
1137
- isDone: (run) => run !== void 0 && target.has(run.status),
1185
+ // A status the caller named as a failure throws, even when it is also a target.
1186
+ isDone: (run) => run !== void 0 && target.has(run.status) && !options.failureStatuses?.has(run.status),
1138
1187
  isFailure: (run) => run !== void 0 && Boolean(options.failureStatuses?.has(run.status)),
1139
1188
  timeout: options.timeout ?? 300,
1140
1189
  initialInterval: options.pollInterval ?? 2,
@@ -1157,6 +1206,220 @@ var ZenRowsBatchClient = class {
1157
1206
  }
1158
1207
  };
1159
1208
 
1209
+ // src/crawl/errors.ts
1210
+ var CRAWL_NOT_ENABLED_CODE = "REQS008";
1211
+ var ZenRowsCrawlError = class extends Error {
1212
+ status;
1213
+ problem;
1214
+ code;
1215
+ detail;
1216
+ extras;
1217
+ /** Seconds from the `Retry-After` header, e.g. 30 on a 429 `too_many_crawls`. */
1218
+ retryAfter;
1219
+ constructor(status, problem, extras, retryAfter) {
1220
+ let message;
1221
+ if (status === 403 && problem?.code === CRAWL_NOT_ENABLED_CODE) {
1222
+ message = `403 Crawl is not enabled for this account (${CRAWL_NOT_ENABLED_CODE})${problem.detail ? `: ${problem.detail}` : ""}`;
1223
+ } else if (problem) {
1224
+ message = `${status} ${problem.title ?? "Error"}: ${problem.detail ?? problem.code ?? "unknown"}`;
1225
+ } else {
1226
+ message = `${status} (no problem body)`;
1227
+ }
1228
+ super(message);
1229
+ this.name = "ZenRowsCrawlError";
1230
+ this.status = status;
1231
+ this.problem = problem;
1232
+ this.code = problem?.code;
1233
+ this.detail = problem?.detail;
1234
+ this.extras = extras;
1235
+ this.retryAfter = retryAfter;
1236
+ }
1237
+ };
1238
+ function crawlError(response, problem, extras) {
1239
+ const raw = response.headers.get("Retry-After");
1240
+ const secs = raw === null || raw.trim() === "" ? Number.NaN : Number(raw);
1241
+ return new ZenRowsCrawlError(
1242
+ response.status,
1243
+ problem,
1244
+ extras,
1245
+ Number.isNaN(secs) || secs < 0 ? void 0 : secs
1246
+ );
1247
+ }
1248
+
1249
+ // src/crawl/client.ts
1250
+ var DEFAULT_CRAWL_API_URL = "https://api.zenrows.com/v1";
1251
+ var DEFAULT_TIMEOUT_SECONDS = 30;
1252
+ var DEFAULT_WAIT_TIMEOUT_SECONDS = 600;
1253
+ var POLL_INTERVAL_SECONDS = 2;
1254
+ var MAX_POLL_INTERVAL_SECONDS = 15;
1255
+ var POLL_BACKOFF = 1.5;
1256
+ var CREATE_RETRY_STATUSES = /* @__PURE__ */ new Set([502, 503, 504]);
1257
+ var STOP_RETRY_STATUSES = /* @__PURE__ */ new Set([429, 502, 503, 504]);
1258
+ var MAX_STOP_RETRY_AFTER_SECONDS = 30;
1259
+ function contentIdOf(content) {
1260
+ let ref;
1261
+ if (typeof content === "string") ref = content;
1262
+ else if (content.content_url) ref = content.content_url;
1263
+ else {
1264
+ throw new TypeError(
1265
+ `content: result for ${content.url} has no content_url (content_status: ${content.content_status ?? "absent"})`
1266
+ );
1267
+ }
1268
+ const id = ref.split("/").pop();
1269
+ if (!id) throw new TypeError(`content: cannot read a content id from ${ref}`);
1270
+ return id;
1271
+ }
1272
+ async function* ndjsonLines(body) {
1273
+ if (!body) return;
1274
+ const decoder = new TextDecoder();
1275
+ let buffered = "";
1276
+ for await (const chunk of body) {
1277
+ buffered += decoder.decode(chunk, { stream: true });
1278
+ let newline = buffered.indexOf("\n");
1279
+ while (newline !== -1) {
1280
+ const line = buffered.slice(0, newline).trim();
1281
+ buffered = buffered.slice(newline + 1);
1282
+ if (line) yield JSON.parse(line);
1283
+ newline = buffered.indexOf("\n");
1284
+ }
1285
+ }
1286
+ const last = (buffered + decoder.decode()).trim();
1287
+ if (last) yield JSON.parse(last);
1288
+ }
1289
+ var ZenRowsCrawlClient = class {
1290
+ apiKey;
1291
+ transport;
1292
+ retries;
1293
+ constructor(apiKey, config = {}) {
1294
+ this.apiKey = apiKey;
1295
+ this.retries = config.retries ?? DEFAULT_RETRIES;
1296
+ this.transport = new BatchTransport(
1297
+ (config.baseURL ?? DEFAULT_CRAWL_API_URL).replace(/\/+$/, ""),
1298
+ apiKey,
1299
+ config.retries,
1300
+ crawlError,
1301
+ (config.timeout ?? DEFAULT_TIMEOUT_SECONDS) * 1e3
1302
+ );
1303
+ }
1304
+ /**
1305
+ * Start a crawl. Sends only the fields you set. With an `idempotencyKey`, a retry returns the
1306
+ * crawl the first request created instead of starting another, and 5xx and network failures
1307
+ * are retried. A 429 `too_many_crawls` is never retried: it throws `ZenRowsCrawlError` with
1308
+ * `retryAfter` set.
1309
+ */
1310
+ create(params, opts = {}) {
1311
+ const body = {
1312
+ url: params.url,
1313
+ depth: params.depth,
1314
+ max_items: params.maxItems,
1315
+ max_pages: params.maxPages,
1316
+ include_patterns: params.includePatterns,
1317
+ exclude_patterns: params.excludePatterns,
1318
+ output_format: params.outputFormat
1319
+ };
1320
+ return this.transport.requestJson("POST", "/crawls", {
1321
+ body,
1322
+ idempotencyKey: opts.idempotencyKey,
1323
+ retryStatuses: CREATE_RETRY_STATUSES
1324
+ });
1325
+ }
1326
+ /** Read a crawl's status, coverage and one page of results (`limit` 1–10000, default 1000). */
1327
+ get(crawlId, options = {}) {
1328
+ return this.transport.requestJson("GET", `/crawls/${encodeURIComponent(crawlId)}`, {
1329
+ query: options
1330
+ });
1331
+ }
1332
+ /** One page of the account's crawls, newest first (`limit` 1–100, default 20). */
1333
+ list(options = {}) {
1334
+ return this.transport.requestJson("GET", "/crawls", { query: options });
1335
+ }
1336
+ /**
1337
+ * Every URL the crawl kept, following `next_cursor` (`limit` is the page size). On a running
1338
+ * crawl it yields the URLs kept so far and returns at the first empty page; it does not poll.
1339
+ * Call `wait()` first to read every result.
1340
+ */
1341
+ async *results(crawlId, options = {}) {
1342
+ let cursor;
1343
+ while (true) {
1344
+ const page = await this.get(crawlId, { ...options, cursor });
1345
+ yield* page.results;
1346
+ if (page.next_cursor === null || page.results.length === 0) return;
1347
+ cursor = page.next_cursor;
1348
+ }
1349
+ }
1350
+ /**
1351
+ * Stop a running crawl. Idempotent: a crawl that already ended answers as it ended.
1352
+ * Because a stop is safe to repeat, `stop()` retries a 429, 502, 503 (including `crawl_busy`,
1353
+ * which means the stop was not saved and the crawl is still running) or 504 up to `retries`
1354
+ * times, waiting `Retry-After` seconds (at most 30; 1 when the header is missing or unusable)
1355
+ * each time, then throws the last error.
1356
+ */
1357
+ async stop(crawlId) {
1358
+ for (let attempt = 0; ; attempt += 1) {
1359
+ try {
1360
+ return await this.transport.requestJson(
1361
+ "POST",
1362
+ `/crawls/${encodeURIComponent(crawlId)}/stop`
1363
+ );
1364
+ } catch (error) {
1365
+ if (!(error instanceof ZenRowsCrawlError) || !STOP_RETRY_STATUSES.has(error.status) || attempt >= this.retries) {
1366
+ throw error;
1367
+ }
1368
+ const waitSeconds = Math.min(error.retryAfter ?? 1, MAX_STOP_RETRY_AFTER_SECONDS);
1369
+ await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1e3));
1370
+ }
1371
+ }
1372
+ }
1373
+ /**
1374
+ * One kept URL's page as fetched (HTML). Pass the content id, a `content_url`, or the
1375
+ * `CrawlResult` itself (its `content_url` is present once `content_status` is `fetched`).
1376
+ * Throws `TypeError` when no content id can be read.
1377
+ */
1378
+ async content(crawlId, contentId) {
1379
+ return this.transport.requestText(
1380
+ "GET",
1381
+ `/crawls/${encodeURIComponent(crawlId)}/contents/${encodeURIComponent(contentIdOf(contentId))}`
1382
+ );
1383
+ }
1384
+ /**
1385
+ * Every result of the crawl in one download: its `status` and the parsed lines (with the
1386
+ * page when the crawl has an `output_format`). On a running crawl, `status` is `running` and
1387
+ * the lines hold only what was kept so far.
1388
+ */
1389
+ async download(crawlId) {
1390
+ const response = await this.transport.requestRaw(
1391
+ "GET",
1392
+ `/crawls/${encodeURIComponent(crawlId)}/download`
1393
+ );
1394
+ return {
1395
+ status: response.headers.get("X-Crawl-Status") ?? void 0,
1396
+ lines: ndjsonLines(response.body)
1397
+ };
1398
+ }
1399
+ /**
1400
+ * Poll until the crawl ends (any status but `running`) or `timeout` seconds pass, and return
1401
+ * it, without results. A `failed` crawl resolves too: read `error`. On timeout the crawl is
1402
+ * returned with status `running` and keeps running.
1403
+ */
1404
+ async wait(crawlId, options = {}) {
1405
+ const deadline2 = Date.now() + (options.timeout ?? DEFAULT_WAIT_TIMEOUT_SECONDS) * 1e3;
1406
+ let interval = POLL_INTERVAL_SECONDS;
1407
+ let latest;
1408
+ while (true) {
1409
+ latest = await this.get(crawlId, { limit: 1 });
1410
+ const remaining = deadline2 - Date.now();
1411
+ if (latest.status !== "running" || remaining <= 0) break;
1412
+ const jitter = 0.8 + Math.random() * 0.4;
1413
+ await new Promise(
1414
+ (resolve) => setTimeout(resolve, Math.min(interval * 1e3 * jitter, remaining))
1415
+ );
1416
+ interval = Math.min(interval * POLL_BACKOFF, MAX_POLL_INTERVAL_SECONDS);
1417
+ }
1418
+ const { results: _results, next_cursor: _cursor, ...crawl } = latest;
1419
+ return crawl;
1420
+ }
1421
+ };
1422
+
1160
1423
  // src/batch/schedule.ts
1161
1424
  var DAYS_OF_WEEK = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
1162
1425
  function validateTimezone(tz, field) {
@@ -1326,10 +1589,13 @@ var ZenRows = class {
1326
1589
  fetchWithRetry;
1327
1590
  /** Client for the Batch API (async job/run/task model) — see `./batch.ts`. */
1328
1591
  batch;
1592
+ /** Client for the Crawl API (new; one start URL in, the URLs behind it out). See `./crawl/client.ts`. */
1593
+ crawl;
1329
1594
  constructor(apiKey, clientConfig = {}) {
1330
1595
  this.apiKey = apiKey;
1331
1596
  this.clientConfig = clientConfig;
1332
1597
  this.batch = new ZenRowsBatchClient(apiKey);
1598
+ this.crawl = new ZenRowsCrawlClient(apiKey, clientConfig.crawl);
1333
1599
  const retries = this.clientConfig.retries ?? 0;
1334
1600
  this.queue = fastq.promise(this, this.worker, this.clientConfig.concurrency ?? 5);
1335
1601
  this.fetchWithRetry = fetchRetry(fetch, {
@@ -1460,6 +1726,7 @@ export {
1460
1726
  AUTO_MIN_CREDITS,
1461
1727
  At,
1462
1728
  BASE_CREDITS,
1729
+ CRAWL_NOT_ENABLED_CODE,
1463
1730
  Calendar,
1464
1731
  CurrentRun,
1465
1732
  DEFAULT_MAX_BYTES_PER_FILE,
@@ -1484,6 +1751,8 @@ export {
1484
1751
  ZenRows,
1485
1752
  ZenRowsBatchClient,
1486
1753
  ZenRowsBatchError,
1754
+ ZenRowsCrawlClient,
1755
+ ZenRowsCrawlError,
1487
1756
  downloadTaskToFile,
1488
1757
  downloadTaskToMemory,
1489
1758
  downloadToDir,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zenrows",
3
- "version": "2.0.3",
3
+ "version": "2.2.0",
4
4
  "description": "ZenRows Node SDK",
5
5
  "main": "./dist/index.cjs",
6
6
  "module": "./dist/index.js",
@@ -19,13 +19,18 @@
19
19
  "scripts": {
20
20
  "build": "tsup src/index.ts --dts --format cjs,esm --clean",
21
21
  "test": "vitest",
22
+ "test:e2e": "vitest run tests/e2e",
22
23
  "format": "biome format --write .",
23
24
  "check": "biome check ."
24
25
  },
25
26
  "keywords": [
26
27
  "sdk",
27
28
  "zenrows",
28
- "scraping"
29
+ "scraping",
30
+ "proxy",
31
+ "data extraction",
32
+ "web scraping",
33
+ "web scraping api"
29
34
  ],
30
35
  "author": "ZenRows",
31
36
  "repository": {
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "module": "ESNext",
6
+ "moduleResolution": "Bundler"
7
+ },
8
+ "include": ["src", "tests"]
9
+ }
package/vitest.config.ts CHANGED
@@ -6,6 +6,8 @@ export default defineConfig({
6
6
  globals: true,
7
7
  include: ["tests/**/*.test.ts"],
8
8
  setupFiles: ["tests/_setup.ts"],
9
+ // Type-level assertions (expectTypeOf) only run under typecheck.
10
+ typecheck: { enabled: true, tsconfig: "./tsconfig.test.json", include: ["tests/**/*.test.ts"] },
9
11
  coverage: {
10
12
  include: ["src/**"],
11
13
  exclude: ["examples/**"],