Files
Vlad Frangu dd6e3b9929 chore!: unify argument validation to zod (#3935)
Replaces the remaining `ow`-based argument validation with `zod` across
all packages and reworks how validation results are consumed and
reported.

Closes #3716

- **`ow` is gone** — every argument check now goes through
`parseArgument(value, schema, label?)` from `@crawlee/utils`, backed by
shared zod schemas (`schemas`, exported via `@crawlee/utils/internal`).
The `@sapphire/shapeshift` checks in `@crawlee/fs-storage` were
converted too, so a single validation library remains.
- **Parse results are used everywhere** — option defaults moved from
destructuring into the schemas (`.default(...)`), and call sites
destructure the typed parse result. `parseArgument` returns `TValue &
z.output<TSchema>`, so call sites keep their declared TS types while
gaining the defaults.
- **Schemas are built once** — all per-call schemas are hoisted to
module scope; crawler/launcher classes build their strict options schema
once as a `static optionsSchema` next to `optionsShape`. The
`urlPatternSchema` for `include`/`exclude` lives in
`enqueue_links/shared.ts`, next to the type it validates.
- **Specific validators instead of `anyObject`** — class-typed options
use `z.instanceof(...)` (`BaseHttpClient`, `Configuration`,
`EventManager`), interface-typed ones use duck-typed `objectWithKeys`
validators (`storageBackend`, `requestManager`, `logger`, …), and
element-typed arrays use the new `schemas.arrayOf(item, 'numbers')`.

`ArgumentValidationError` (replacing ow's `ArgumentError`) renders one
line per issue: the expected type, the received type and value folded
into one clause, the offending field path, and the validated interface:

```text
// v3 (ow) — first issue only
Expected property `maxRequestRetries` to be of type `number` but received type `string` in object `HttpCrawlerOptions`

// v4 (zod) — every issue, one line each
Invalid input: expected number, received the string `many` at `maxRequestRetries` in `HttpCrawlerOptions`
Invalid input: expected an array of numbers, received the number `500` at `additionalHttpErrorStatusCodes` in `HttpCrawlerOptions`
Invalid input: expected boolean, received the string `yes` at `retryOnBlocked` in `HttpCrawlerOptions`
```

Details worth knowing:

- Union failures expand into one line per failed arm (zod's own message
is a bare "Invalid input").
- `NaN` is named as itself, an empty string renders as `''`, and arrays
name their element type (``expected an array of URL patterns``) — none
of which ow or stock zod reported.
- `new Request('https://…')` gets a targeted hint pointing at the `{ url
}` object form.
- For programmatic handling, the error exposes zod's structured output:
`error.issues` and the raw `ZodError` as a typed `cause`.

The migration is documented in the v4 upgrading guide
(`docs/upgrading/upgrading_v4.md`), including a rename-cheat-sheet
entry.

- Custom HTTP clients must now **extend `BaseHttpClient`** from
`@crawlee/http-client` rather than just implementing the interface (all
shipped clients already do; `LazyDefaultHttpClient` was converted). Same
applies to test mocks — `Object.create(BaseHttpClient.prototype)` works.
- One caveat of consuming parse results: zod object schemas return a
pruned plain copy, so options holding class instances are validated with
passthrough schemas (`z.custom`-based) to keep their prototypes — there
are comments at the relevant schemas.
- Fixes a few latent gaps surfaced along the way: `Request.state` now
accepts `RequestState.SKIPPED` (validated via `z.enum(RequestState)`),
and the publish-time catalog inlining covers `optionalDependencies`.
- `ArgumentValidationError` and its formatter are intentionally kept
close to the copy in apify/apify-client-js#986 — a follow-up may extract
them into a shared package.

---------

Co-authored-by: Martin Adámek <banan23@gmail.com>
2026-08-18 17:48:52 +02:00

180 lines
5.9 KiB
TypeScript

/* eslint-disable import/no-dynamic-require */
import { execSync } from 'node:child_process';
import { copyFileSync, readFileSync, writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
const require = createRequire(import.meta.url);
const options = process.argv.slice(2).reduce((args, arg) => {
const [key, value] = arg.split('=');
args[key.substring(2)] = value ?? true;
return args;
}, {} as any);
function copy(filename: string, from: string, to: string): void {
copyFileSync(resolve(from, filename), resolve(to, filename));
}
function rewrite(path: string, replacer: (from: string) => string): void {
try {
const file = readFileSync(path).toString();
const replaced = replacer(file);
writeFileSync(path, replaced);
} catch {
// not found
}
}
let rootVersion: string;
function getRootVersion(bump = true): string {
if (rootVersion) {
return rootVersion;
}
const pkg = require(resolve(root, './lerna.json'));
rootVersion = pkg.version.replace(/^(\d+\.\d+\.\d+)-?.*$/, '$1');
if (bump) {
const parts = rootVersion.split('.');
const inc = bump ? 1 : 0;
const canary = String(options.canary).toLowerCase();
switch (canary) {
case 'major': {
parts[0] = `${+parts[0] + inc}`;
parts[1] = '0';
parts[2] = '0';
break;
}
case 'minor': {
parts[1] = `${+parts[0] + inc}`;
parts[2] = '0';
break;
}
case 'patch':
default:
parts[2] = `${+parts[2] + inc}`;
}
rootVersion = parts.join('.');
}
return rootVersion;
}
/**
* Checks next dev version number based on the `crawlee` meta package via `npm show`.
* We always use this package, so we ensure the version is the same for each package in the monorepo.
*/
function getNextVersion() {
const versions: string[] = [];
try {
const versionString = execSync(`npm show @crawlee/core versions --json`, { encoding: 'utf8', stdio: 'pipe' });
const parsed = JSON.parse(versionString) as string[];
versions.push(...parsed);
} catch {
// the package might not have been published yet
}
const version = getRootVersion();
if (versions.some((v) => v === version)) {
console.error(
`before-deploy: A release with version ${version} already exists. Please increment version accordingly.`,
);
process.exit(1);
}
const preid = options.preid ?? 'alpha';
const prereleaseNumbers = versions
.filter((v) => v.startsWith(`${version}-${preid}.`))
.map((v) => Number(v.match(/\.(\d+)$/)?.[1]));
const lastPrereleaseNumber = Math.max(-1, ...prereleaseNumbers);
return `${version}-${preid}.${lastPrereleaseNumber + 1}`;
}
// as we publish only the dist folder, we need to copy some meta files inside (readme/license/package.json)
// also changes paths inside the copied `package.json` (`dist/index.js` -> `index.js`)
const root = resolve(import.meta.dirname, '..');
const target = resolve(process.cwd(), 'dist');
const pkgPath = resolve(process.cwd(), 'package.json');
if (options.canary) {
const pkgJson = require(pkgPath);
const nextVersion = getNextVersion();
pkgJson.version = nextVersion;
for (const dep of Object.keys(pkgJson.dependencies)) {
if ((dep.startsWith('@crawlee/') && dep !== '@crawlee/fs-storage-native') || dep === 'crawlee') {
const prefix = pkgJson.dependencies[dep].startsWith('^') ? '^' : '';
pkgJson.dependencies[dep] = prefix + nextVersion;
}
}
console.info(`canary: setting version to ${nextVersion}`);
writeFileSync(pkgPath, `${JSON.stringify(pkgJson, null, 4)}\n`);
}
if (options['pin-versions']) {
const pkgJson = require(pkgPath);
const version = getRootVersion(false);
for (const dep of Object.keys(pkgJson.dependencies ?? {})) {
if ((dep.startsWith('@crawlee/') && dep !== '@crawlee/fs-storage-native') || dep === 'crawlee') {
pkgJson.dependencies[dep] = version;
}
}
console.info(`pin-versions: version ${version}`, pkgJson.dependencies);
writeFileSync(pkgPath, `${JSON.stringify(pkgJson, null, 4)}\n`);
}
/**
* `catalog:` specifiers are a pnpm workspace feature; lerna publishes the manifest verbatim,
* so they have to be inlined with the versions from `pnpm-workspace.yaml` before publishing.
*/
function getCatalogVersions(): Record<string, string> {
const workspaceYaml = readFileSync(resolve(root, 'pnpm-workspace.yaml')).toString();
const catalogBlock = workspaceYaml.match(/^catalog:\n((?: {2}.+\n)+)/m)?.[1] ?? '';
const versions: Record<string, string> = {};
for (const line of catalogBlock.split('\n')) {
const match = line.match(/^ {2}["']?([^"':]+)["']?:\s*["']?(.+?)["']?\s*$/);
if (match) versions[match[1]] = match[2];
}
return versions;
}
copy('README.md', root, target);
copy('LICENSE.md', root, target);
copy('package.json', process.cwd(), target);
rewrite(resolve(target, 'package.json'), (pkg) => {
const catalog = getCatalogVersions();
const manifest = JSON.parse(pkg.replace(/dist\//g, '').replace(/src\/(.*)\.ts/g, '$1.js'));
for (const deps of [
manifest.dependencies,
manifest.devDependencies,
manifest.peerDependencies,
manifest.optionalDependencies,
]) {
for (const dep of Object.keys(deps ?? {})) {
if (deps[dep] === 'catalog:') {
if (!catalog[dep]) throw new Error(`Missing catalog entry for '${dep}' in pnpm-workspace.yaml`);
deps[dep] = catalog[dep];
}
}
}
return `${JSON.stringify(manifest, null, 4)}\n`;
});
rewrite(resolve(target, 'utils.js'), (pkg) => pkg.replace('../package.json', './package.json'));