Skip to content
Merged
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
470 changes: 148 additions & 322 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,10 @@
"utp-native@2.5.3": true
},
"overrides": {
"@node-datachannel/android-arm64": "0.33.0",
"esbuild": "^0.28.2",
"ip": "^2.0.1",
"node-datachannel": "^0.33.1",
"uint8-util": "2.2.6"
}
}
96 changes: 60 additions & 36 deletions scripts/cli-entry.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,47 +12,71 @@ if (major < 26) {
process.exit(1);
}

// The WebRTC stack (webtorrent -> simple-peer -> webrtc-polyfill) eagerly
// requires node-datachannel's native binary, which only install scripts
// download; npm 12 skips those scripts by default, so the binary is often
// absent and the eager import would kill startup. When it cannot load,
// resolve webrtc-polyfill to an inert stub instead: simple-peer then reports
// Resolve webrtc-polyfill to an inert stub: simple-peer then reports
// WEBRTC_SUPPORT = false and downloads run on TCP/uTP and DHT peers alone.
try {
require('node-datachannel');
} catch (err) {
// Returns false on Node 22.0 to 22.14, which has no module.registerHooks and
// so cannot redirect the eager import at all.
function useWebrtcStub() {
var Module = require('node:module');
if (typeof Module.registerHooks === 'function') {
var stubUrl = require('node:url')
.pathToFileURL(require('node:path').join(__dirname, 'webrtc-stub.mjs'))
.href;
Module.registerHooks({
resolve: function (specifier, context, nextResolve) {
if (specifier === 'webrtc-polyfill') {
return { url: stubUrl, shortCircuit: true };
}
return nextResolve(specifier, context);
},
});
process.stderr.write(
'torlnk: WebRTC peers unavailable (native module not installed); ' +
'TCP/UDP peers still work. https://github.com/baairon/torlink/issues/60\n'
);
if (typeof Module.registerHooks !== 'function') return false;
var stubUrl = require('node:url')
.pathToFileURL(require('node:path').join(__dirname, 'webrtc-stub.mjs'))
.href;
Module.registerHooks({
resolve: function (specifier, context, nextResolve) {
if (specifier === 'webrtc-polyfill') {
return { url: stubUrl, shortCircuit: true };
}
return nextResolve(specifier, context);
},
});
return true;
}

// The same switch, thrown on purpose. On some machines node-datachannel's
// native event loop burns a core for as long as torlink is open, idle or not
// (issue #119), and nothing inside it can be turned down at runtime. Rather
// than decide for a whole platform, let the person watching their fan spin opt
// out on their own machine: TCP/uTP and DHT peers are where torlink's swarms
// are anyway. Presence is the switch, like TORLINK_NO_UPDATE_CHECK.
if (process.env.TORLINK_NO_WEBRTC || process.env.KLINK_NO_WEBRTC) {
if (useWebrtcStub()) {
process.stderr.write('torlnk: WebRTC peers disabled by TORLINK_NO_WEBRTC.\n');
} else {
// Node 22.0 to 22.14 has no module.registerHooks, so the eager import
// cannot be redirected; a clear explanation beats the raw module error.
process.stderr.write(
'\ntorlnk needs the WebRTC native module (node-datachannel), and it is\n' +
'not installed. Either upgrade to Node 22.15+ (torlnk then runs\n' +
'without WebRTC peers), or install the build tools and reinstall:\n' +
' Fedora: sudo dnf install cmake gcc-c++ openssl-devel libstdc++-static\n' +
' Debian / Ubuntu: sudo apt install cmake g++ libssl-dev\n' +
' macOS: xcode-select --install\n' +
' Windows: install CMake and Visual Studio Build Tools\n' +
'On npm 12, also allow install scripts: npm approve-scripts\n\n' +
'https://github.com/baairon/torlink/issues/60\n\n'
'torlnk: TORLINK_NO_WEBRTC needs Node 22.15 or later to take effect; ' +
'WebRTC peers stay enabled on v' + process.versions.node + '.\n'
);
process.exit(1);
}
} else {
// The WebRTC stack (webtorrent -> simple-peer -> webrtc-polyfill) eagerly
// requires node-datachannel's native binary, which only install scripts
// download; npm 12 skips those scripts by default, so the binary is often
// absent and the eager import would kill startup.
try {
require('node-datachannel');
} catch (err) {
if (useWebrtcStub()) {
process.stderr.write(
'torlnk: WebRTC peers unavailable (native module not installed); ' +
'TCP/UDP peers still work. https://github.com/baairon/torlink/issues/60\n'
);
} else {
// Node 22.0 to 22.14 has no module.registerHooks, so the eager import
// cannot be redirected; a clear explanation beats the raw module error.
process.stderr.write(
'\ntorlnk needs the WebRTC native module (node-datachannel), and it is\n' +
'not installed. Either upgrade to Node 22.15+ (torlnk then runs\n' +
'without WebRTC peers), or install the build tools and reinstall:\n' +
' Fedora: sudo dnf install cmake gcc-c++ openssl-devel libstdc++-static\n' +
' Debian / Ubuntu: sudo apt install cmake g++ libssl-dev\n' +
' macOS: xcode-select --install\n' +
' Windows: install CMake and Visual Studio Build Tools\n' +
'On npm 12, also allow install scripts: npm approve-scripts\n\n' +
'https://github.com/baairon/torlink/issues/60\n\n'
);
process.exit(1);
}
}
}

Expand Down
30 changes: 30 additions & 0 deletions scripts/cli-entry.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { describe, it, expect } from "vitest";

const entry = fileURLToPath(new URL("./cli-entry.cjs", import.meta.url));

// cli-entry settles the WebRTC question and only then imports ./index.js,
// which exists in dist/ and not in scripts/. Run from here that import fails
// and the process exits, which leaves exactly the part under test on stderr.
function runEntry(env) {
const res = spawnSync(process.execPath, [entry], {
env: { ...process.env, ...env },
encoding: "utf8",
});
return res.stderr ?? "";
}

describe("TORLINK_NO_WEBRTC", () => {
it("turns WebRTC off when it is set", () => {
// The second branch is Node 22.0-22.14, where module.registerHooks does not
// exist and the opt-out says so rather than pretending to work.
expect(runEntry({ TORLINK_NO_WEBRTC: "1" })).toMatch(
/WebRTC peers disabled by TORLINK_NO_WEBRTC|TORLINK_NO_WEBRTC needs Node 22\.15/,
);
});

it("says nothing about the flag when it is unset", () => {
expect(runEntry({ TORLINK_NO_WEBRTC: "" })).not.toMatch(/TORLINK_NO_WEBRTC/);
});
});
38 changes: 38 additions & 0 deletions src/cli/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,44 @@ describe("parseCliArgs", () => {
expect(parseCliArgs(["update"])).toEqual({ kind: "update", force: false });
expect(parseCliArgs(["update", "--force"])).toEqual({ kind: "update", force: true });
});
it("parses headless searches", () => {
expect(parseCliArgs(["search", "ubuntu"])).toEqual({ kind: "search", query: "ubuntu" });
expect(parseCliArgs(["search", "example", "movie", "--category", "movies"])).toEqual({
kind: "search",
query: "example movie",
category: "movies",
});
expect(parseCliArgs(["search", "--category", "games", "ubuntu"])).toEqual({
kind: "search",
query: "ubuntu",
category: "games",
});
expect(parseCliArgs(["search", "tolkien", "--category", "ebooks"])).toEqual({
kind: "search",
query: "tolkien",
category: "ebooks",
});
expect(parseCliArgs(["search", "sanderson", "--category", "audiobooks"])).toEqual({
kind: "search",
query: "sanderson",
category: "audiobooks",
});
});
it("rejects invalid headless searches", () => {
expect(parseCliArgs(["search"])).toEqual({ kind: "invalid", arg: "search (missing query)" });
expect(parseCliArgs(["search", "ubuntu", "--category", "invalidcat"])).toEqual({
kind: "invalid",
arg: "search (invalid category 'invalidcat')",
});
expect(parseCliArgs(["search", "ubuntu", "--limit", "10"])).toEqual({
kind: "invalid",
arg: "search (unknown --limit)",
});
expect(parseCliArgs(["search", "ubuntu", "--category"])).toEqual({
kind: "invalid",
arg: "search (invalid --category)",
});
});
it("parses watch with a directory", () => {
expect(parseCliArgs(["watch", "/srv/blackhole"])).toEqual({
kind: "watch",
Expand Down
29 changes: 29 additions & 0 deletions src/cli/args.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { isInfoHash } from "../sources/magnet";
import { parseDuration } from "../util/duration";

export type SearchCategory = "games" | "movies" | "tv" | "anime" | "ebooks" | "audiobooks";

export type CliCommand =
| { kind: "version" }
| { kind: "help" }
Expand All @@ -26,6 +28,7 @@ export type CliCommand =
| { kind: "files"; port?: number; host?: string; token?: string; dir?: string; daemon?: boolean }
| { kind: "attach" }
| { kind: "update"; force?: boolean }
| { kind: "search"; query: string; category?: SearchCategory }
| { kind: "invalid"; arg: string };

// Valueless boolean flags for the headless subcommands (everything else is a
Expand Down Expand Up @@ -77,6 +80,30 @@ export function parseCliArgs(argv: string[]): CliCommand {
if (a === "--help" || a === "-h") return { kind: "help" };
if (a === "attach") return { kind: "attach" };
if (a === "update") return { kind: "update", force: args.slice(1).includes("--force") };
if (a === "search") {
const { flags, rest } = readFlags(args.slice(1));
const unknownFlag = Object.keys(flags).find((flag) => flag !== "category");
const danglingFlag = rest.find((arg) => arg.startsWith("--"));
if (unknownFlag) return { kind: "invalid", arg: `search (unknown --${unknownFlag})` };
if (danglingFlag) return { kind: "invalid", arg: `search (invalid ${danglingFlag})` };

const query = rest.join(" ").trim();
if (!query) return { kind: "invalid", arg: "search (missing query)" };

const category = flags.category;
if (category === undefined) return { kind: "search", query };
if (
category === "games" ||
category === "movies" ||
category === "tv" ||
category === "anime" ||
category === "ebooks" ||
category === "audiobooks"
) {
return { kind: "search", query, category };
}
return { kind: "invalid", arg: `search (invalid category '${category}')` };
}
if (a === "watch") {
const { bools, rest: r0 } = splitBooleans(args.slice(1));
const { flags, rest } = readFlags(r0);
Expand Down Expand Up @@ -129,6 +156,8 @@ usage
torlnk open the search TUI
torlnk "magnet:?xt=..." start a download on launch
torlnk path/to/file.torrent open a .torrent file on launch
torlnk search <query> headless: print search results as JSON
[--category games|movies|tv|anime|ebooks|audiobooks]
torlnk watch <dir> headless: download torrents dropped into <dir>
torlnk serve headless: HTTP add API (POST /add) on :9161
torlnk files headless: serve downloads over HTTP on :9160
Expand Down
102 changes: 102 additions & 0 deletions src/cli/search.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cachedSearch } from "../sources/cache";
import { sourcesByGroup } from "../sources/registry";
import type { TorrentResult } from "../sources/types";
import { HttpError } from "../util/net";
import { runSearch } from "./search";

vi.mock("../sources/cache", () => ({ cachedSearch: vi.fn() }));

const searchMock = vi.mocked(cachedSearch);

function result(
infoHash: string,
seeders: number,
added: number,
source: TorrentResult["source"],
): TorrentResult {
return {
infoHash,
name: infoHash,
source,
sizeBytes: 1,
seeders,
leechers: 0,
added,
magnet: `magnet:?xt=urn:btih:${infoHash}`,
};
}

beforeEach(() => {
searchMock.mockReset();
});

describe("runSearch", () => {
it("selects category sources and preserves partial failures", async () => {
searchMock.mockImplementation(async (source) => {
if (source.id === "yts") return [result("same", 10, 100, source.id)];
if (source.id === "tpb-movies") {
return [result("same", 20, 100, source.id), result("other", 15, 200, source.id)];
}
throw new HttpError(503, "source unavailable");
});

const execution = await runSearch({ query: "example movie", category: "movies" });
const movieSourceIds = sourcesByGroup()
.find(({ group }) => group === "Movies")!
.sources.map(({ id }) => id);

expect(searchMock.mock.calls.map(([source]) => source.id)).toEqual(movieSourceIds);
expect(execution.exitCode).toBe(0);
expect(execution.document.sources.yts).toEqual({ ok: true, count: 1, error: null, code: null });
expect(execution.document.sources["x1337-movies"]).toEqual({
ok: false,
count: 0,
error: "source unavailable",
code: "HTTP 503",
});
expect(
execution.document.results.map(({ infoHash, seeders }) => ({ infoHash, seeders })),
).toEqual([
{ infoHash: "same", seeders: 20 },
{ infoHash: "other", seeders: 15 },
]);
});

it("selects ebooks category sources correctly", async () => {
searchMock.mockResolvedValue([]);
const execution = await runSearch({ query: "tolkien", category: "ebooks" });
const ebookSourceIds = sourcesByGroup()
.find(({ group }) => group === "E-Books")!
.sources.map(({ id }) => id);

expect(searchMock.mock.calls.map(([source]) => source.id)).toEqual(ebookSourceIds);
expect(execution.exitCode).toBe(0);
expect(execution.document.category).toBe("ebooks");
});

it("exits successfully when every source returns an empty result", async () => {
searchMock.mockResolvedValue([]);

const execution = await runSearch({ query: "legitimate empty search" });

expect(execution.exitCode).toBe(0);
expect(execution.document.category).toBe("all");
expect(execution.document.count).toBe(0);
});

it("returns diagnostic output and exit 1 when every source fails", async () => {
searchMock.mockRejectedValue(new Error("offline"));

const execution = await runSearch({ query: "ubuntu", category: "games" });

expect(execution.exitCode).toBe(1);
expect(execution.document.results).toEqual([]);
expect(execution.document.sources.fitgirl).toEqual({
ok: false,
count: 0,
error: "offline",
code: "no response",
});
});
});
Loading