Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .yarn/versions/7111fix0.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
releases:
"@yarnpkg/cli": patch
"@yarnpkg/core": patch
"@yarnpkg/plugin-essentials": patch
"@yarnpkg/plugin-typescript": patch

declined:
- "@yarnpkg/extensions"
- "@yarnpkg/plugin-catalog"
- "@yarnpkg/plugin-compat"
- "@yarnpkg/plugin-constraints"
- "@yarnpkg/plugin-dlx"
- "@yarnpkg/plugin-exec"
- "@yarnpkg/plugin-file"
- "@yarnpkg/plugin-git"
- "@yarnpkg/plugin-github"
- "@yarnpkg/plugin-http"
- "@yarnpkg/plugin-init"
- "@yarnpkg/plugin-interactive-tools"
- "@yarnpkg/plugin-jsr"
- "@yarnpkg/plugin-link"
- "@yarnpkg/plugin-nm"
- "@yarnpkg/plugin-npm"
- "@yarnpkg/plugin-npm-cli"
- "@yarnpkg/plugin-pack"
- "@yarnpkg/plugin-patch"
- "@yarnpkg/plugin-pnp"
- "@yarnpkg/plugin-pnpm"
- "@yarnpkg/plugin-stage"
- "@yarnpkg/plugin-version"
- "@yarnpkg/plugin-workspace-tools"
- "@yarnpkg/builder"
- "@yarnpkg/doctor"
- "@yarnpkg/nm"
- "@yarnpkg/pnp"
- "@yarnpkg/pnpify"
- "@yarnpkg/sdks"
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import {Manifest} from '@yarnpkg/core';
import {PortablePath, ppath, xfs} from '@yarnpkg/fslib';
import {merge} from 'es-toolkit/compat';
import events from 'events';
import {AddressInfo} from 'net';
import net from 'net';
import {fs, yarn} from 'pkg-tests-core';

const {unpackToDirectory} = fs;
Expand Down Expand Up @@ -66,6 +69,52 @@ describe(`Plugins`, () => {
}),
);

test(
`it should warn and add the package without @types when the Algolia index can't be reached`,
makeTemporaryEnv({}, async ({path, run, source}) => {
// Accepts the connection then immediately drops it, just like a
// firewall sitting between Yarn and Algolia would
const blackhole = net.createServer(socket => socket.destroy());

blackhole.listen(0, `127.0.0.1`);
await events.once(blackhole, `listening`);

try {
const {port} = blackhole.address() as AddressInfo;

await xfs.writeFilePromise(ppath.join(path, `tsconfig.json`), ``);

// Only the Algolia lookup goes through the blackhole; the registry
// is configured through the environment and stays reachable
await xfs.writeFilePromise(ppath.join(path, `.yarnrc.yml`), [
`httpRetry: 0`,
`networkSettings:`,
` "*.algolia.net":`,
` httpsProxy: "http://127.0.0.1:${port}"`,
` "*.algolianet.com":`,
` httpsProxy: "http://127.0.0.1:${port}"`,
].join(`\n`));

const {stdout} = await run(`add`, `is-number`);

expect(stdout).toMatch(/Couldn't query Algolia's npm-search index/);

const manifest = await readManifest(path);

expect(manifest).toMatchObject({
dependencies: {
[`is-number`]: `^2.0.0`,
},
});

expect(manifest).not.toHaveProperty(`devDependencies`);
} finally {
blackhole.close();
await events.once(blackhole, `close`);
}
}),
);

test(
`it should automatically enable automatic @types insertion in the current workspace when tsEnableAutoTypes is set to true`,
makeTemporaryMonorepoEnv({
Expand Down
31 changes: 22 additions & 9 deletions packages/plugin-essentials/sources/commands/add.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {BaseCommand, WorkspaceRequiredError} from '@yarnpkg/cli';
import {Cache, Configuration, Descriptor, formatUtils, LightReport, MessageName} from '@yarnpkg/core';
import {Project, Workspace, Ident, InstallMode} from '@yarnpkg/core';
import {Project, StreamReport, Workspace, Ident, InstallMode} from '@yarnpkg/core';
import {structUtils} from '@yarnpkg/core';
import {PortablePath} from '@yarnpkg/fslib';
import {Command, Option, Usage, UsageError} from 'clipanion';
Expand Down Expand Up @@ -331,15 +331,28 @@ export default class AddCommand extends BaseCommand {
}
}

await configuration.triggerMultipleHooks(
(hooks: Hooks) => hooks.afterWorkspaceDependencyAddition,
afterWorkspaceDependencyAdditionList,
);
// Those hooks may report warnings (eg. plugin-typescript when it can't
// reach Algolia); without a report around them Node would print them as
// raw process warnings rather than as regular Yarn messages
const hookReport = await StreamReport.start({
configuration,
includeFooter: false,
json: this.json,
stdout: this.context.stdout,
}, async () => {
await configuration.triggerMultipleHooks(
(hooks: Hooks) => hooks.afterWorkspaceDependencyAddition,
afterWorkspaceDependencyAdditionList,
);

await configuration.triggerMultipleHooks(
(hooks: Hooks) => hooks.afterWorkspaceDependencyReplacement,
afterWorkspaceDependencyReplacementList,
);
});

await configuration.triggerMultipleHooks(
(hooks: Hooks) => hooks.afterWorkspaceDependencyReplacement,
afterWorkspaceDependencyReplacementList,
);
if (hookReport.hasErrors())
return hookReport.exitCode();

if (askedQuestions)
this.context.stdout.write(`\n`);
Expand Down
63 changes: 53 additions & 10 deletions packages/plugin-typescript/sources/typescriptUtils.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import {Request, Requester, Response} from '@algolia/requester-common';
import {Configuration, Descriptor} from '@yarnpkg/core';
import {httpUtils, structUtils} from '@yarnpkg/core';
import algoliasearch from 'algoliasearch';
import {Request, Requester, Response} from '@algolia/requester-common';
import {Configuration, Descriptor} from '@yarnpkg/core';
import {formatUtils, httpUtils, structUtils} from '@yarnpkg/core';
import algoliasearch from 'algoliasearch';

// Note that the appId and appKey are specific to Yarn's plugin-typescript - please
// don't use them anywhere else without asking Algolia's permission
const ALGOLIA_API_KEY = `e8e1bd300d860104bb8c58453ffa1eb4`;
const ALGOLIA_APP_ID = `OFCNCOG2CU`;

// Maximum time (in milliseconds) we're willing to wait for Algolia to tell us
// whether a package ships its types through DefinitelyTyped. Without this cap a
// restricted network (eg. a corporate proxy that silently drops the request)
// would make `yarn add` hang indefinitely.
// See https://github.com/yarnpkg/berry/issues/7111
const ALGOLIA_TIMEOUT = 10000;

interface AlgoliaObj {
types?: {
ts?: string;
Expand All @@ -19,25 +26,55 @@ export const hasDefinitelyTyped = async (
configuration: Configuration,
) => {
const stringifiedIdent = structUtils.stringifyIdent(descriptor);
const algoliaClient = createAlgoliaClient(configuration);
const abortController = new AbortController();
const algoliaClient = createAlgoliaClient(configuration, abortController.signal);
const index = algoliaClient.initIndex(`npm-search`);

// Note that we can't use `AbortSignal.timeout` here: its timer lives in Node's
// internals rather than on the global `setTimeout`, so tests can't advance it.
const timeout = setTimeout(() => {
abortController.abort(new Error(`Timed out after ${ALGOLIA_TIMEOUT}ms`));
}, ALGOLIA_TIMEOUT);

try {
const packageInfo = await index.getObject<AlgoliaObj>(stringifiedIdent, {attributesToRetrieve: [`types`]});

return packageInfo.types?.ts === `definitely-typed`;
} catch {
} catch (error) {
// A timeout or a network error (eg. a proxy blocking the request) shouldn't
// prevent the package from being added - we just can't tell whether it needs
// a matching `@types` package, so we let the user know and carry on.
if (abortController.signal.aborted || error?.name === `RetryError`)
reportAutoTypesError(configuration, descriptor, error);
Comment thread
sebdanielsson marked this conversation as resolved.

return false;
} finally {
clearTimeout(timeout);
}
};

const createAlgoliaClient = (configuration: Configuration) => {
const reportAutoTypesError = (configuration: Configuration, descriptor: Descriptor, error: Error) => {
const prettyIdent = structUtils.prettyIdent(configuration, descriptor);

// Reported as two warnings rather than one multi-line message, as reports
// only prefix the first line of what they're given
process.emitWarning(
`Couldn't query Algolia's npm-search index to check whether ${prettyIdent} needs a matching @types package (${error.message}); the package will be added without it.`,
);

process.emitWarning(
`You can disable this lookup by setting ${formatUtils.pretty(configuration, `tsEnableAutoTypes`, formatUtils.Type.SETTING)} to false in your .yarnrc.yml (or by setting the YARN_TS_ENABLE_AUTO_TYPES="false" environment variable).`,
);
};

const createAlgoliaClient = (configuration: Configuration, signal: AbortSignal) => {
const requester: Requester = {
async send(request: Request): Promise<Response> {
try {
const response = await httpUtils.request(request.url, request.data || null, {
configuration,
headers: request.headers,
signal,
});

return {
Expand All @@ -46,10 +83,16 @@ const createAlgoliaClient = (configuration: Configuration) => {
status: response.statusCode,
};
} catch (error) {
if (signal.aborted)
throw signal.reason;

// Connection errors (eg. a proxy refusing the request) don't always
// carry a `response`, so we have to guard against it to avoid throwing
// an unrelated `TypeError` from within the requester itself.
return {
content: error.response.body,
isTimedOut: false,
status: error.response.statusCode,
content: error.response?.body,
isTimedOut: error.code === `ETIMEDOUT`,
status: error.response?.statusCode ?? 0,
};
}
}};
Expand Down
91 changes: 91 additions & 0 deletions packages/plugin-typescript/tests/typescriptUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import {Configuration, Hooks, Plugin, httpUtils, structUtils} from '@yarnpkg/core';
import {PortablePath} from '@yarnpkg/fslib';

import {hasDefinitelyTyped} from '../sources/typescriptUtils';
import plugin from '../sources';

const requestMock = jest.fn<void, [AbortSignal]>();

const descriptor = structUtils.makeDescriptor(
structUtils.makeIdent(null, `is-number`),
`unknown`,
);

const makeConfiguration = (executeRequest: (signal: AbortSignal) => Promise<httpUtils.Response>) => {
const testPlugin: Plugin<Hooks> = {
hooks: {
wrapNetworkRequest: async (_executor, {signal}) => {
if (typeof signal === `undefined`)
throw new Error(`Expected the Algolia request to receive an abort signal`);

requestMock(signal);

return () => executeRequest(signal);
},
},
};

return Configuration.create(PortablePath.root, new Map<string, Plugin>([
[`@yarnpkg/plugin-typescript`, plugin],
[`test-plugin`, testPlugin],
]));
};

const flushPromises = async () => {
for (let t = 0; t < 10; t++) {
await Promise.resolve();
}
};

afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
requestMock.mockReset();
});

describe(`typescriptUtils`, () => {
describe(`hasDefinitelyTyped`, () => {
it(`aborts the Algolia request when the lookup times out`, async () => {
jest.useFakeTimers();

const emitWarning = jest.spyOn(process, `emitWarning`).mockImplementation(() => {});
const configuration = makeConfiguration(signal => {
return new Promise((_resolve, reject) => {
if (signal.aborted) {
reject(signal.reason);
} else {
signal.addEventListener(`abort`, () => {
reject(signal.reason);
}, {once: true});
}
});
});

const result = hasDefinitelyTyped(descriptor, configuration);

await flushPromises();
expect(requestMock).toHaveBeenCalledTimes(1);

jest.advanceTimersByTime(10_000);

await expect(result).resolves.toBe(false);
await flushPromises();

expect(requestMock).toHaveBeenCalledTimes(1);
expect(requestMock.mock.calls[0][0].aborted).toBe(true);
expect(emitWarning).toHaveBeenCalledWith(expect.stringContaining(`Couldn't query Algolia's npm-search index`));
});

it(`warns and returns false when all Algolia hosts are unreachable`, async () => {
const emitWarning = jest.spyOn(process, `emitWarning`).mockImplementation(() => {});
const configuration = makeConfiguration(async () => {
throw new Error(`Network unavailable`);
});

await expect(hasDefinitelyTyped(descriptor, configuration)).resolves.toBe(false);

expect(requestMock).toHaveBeenCalledTimes(4);
expect(emitWarning).toHaveBeenCalledWith(expect.stringContaining(`Couldn't query Algolia's npm-search index`));
});
});
});
Loading
Loading