dd6e3b9929
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>
180 lines
5.9 KiB
TypeScript
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'));
|