diff --git a/README.md b/README.md index 3807845..ab4fe9b 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ would only work in one of those places. ## CLI ```sh -moshpit-resolve [--moshpit] [--clearnet-resolves] [--registry URL] [--console URL] [--parking URL] [--timeout MS] [--json] +moshpit-resolve [--moshpit] [--clearnet-resolves] [--registry URL] [--console URL] [--parking URL] [--timeout MS] [--concurrency N] [--json] ``` ``` @@ -84,6 +84,17 @@ every navigation, and the thing you need when a name goes somewhere unexpected. Use `--json` when another tool needs the registry answer, decision, reason, and destination without parsing the human-readable summary. +Pass more than one name to inspect a batch in a single process. Results stay in +input order, normalized duplicates share one lookup, and no more than eight +registry requests run at once by default. Use `--concurrency` to tune that +limit for a self-hosted registry. Multi-name JSON output is an array, while the +existing single-name JSON object is unchanged. + +```sh +moshpit-resolve blue.eggs red.eggs missing.eggs --json +moshpit-resolve one.eggs two.eggs three.eggs --concurrency 2 +``` + Registry lookups use an eight-second deadline by default. Scripts and self-hosted deployments can lower it without changing the resolution policy: diff --git a/bin/moshpit-resolve.mjs b/bin/moshpit-resolve.mjs index d576abd..d4d582c 100755 --- a/bin/moshpit-resolve.mjs +++ b/bin/moshpit-resolve.mjs @@ -5,12 +5,12 @@ // reason — which is the part you need when a name goes somewhere unexpected. import { - DEFAULT_LOOKUP_TIMEOUT_MS, parseRegistryName, resolutionFor, + DEFAULT_CONCURRENCY, DEFAULT_LOOKUP_TIMEOUT_MS, parseRegistryName, resolutionFor, } from "../lib/index.mjs"; const USAGE = `moshpit-resolve — where a Moshpit name would send you - moshpit-resolve [--moshpit] [--clearnet-resolves] [--registry URL] [--console URL] [--parking URL] [--timeout MS] + moshpit-resolve [--moshpit] [--clearnet-resolves] [--registry URL] [--console URL] [--parking URL] [--timeout MS] [--concurrency N] --moshpit let a registered name beat a clearnet answer --clearnet-resolves pretend the real internet has an answer for this name @@ -18,14 +18,18 @@ const USAGE = `moshpit-resolve — where a Moshpit name would send you --console URL a custom namespace management console --parking URL a custom base for unpointed names --timeout MS registry request deadline (default: ${DEFAULT_LOOKUP_TIMEOUT_MS}) + --concurrency N maximum simultaneous batch lookups (default: ${DEFAULT_CONCURRENCY}) --json print a machine-readable resolution decision Prints the destination and the reason for it. No browser, no navigation.`; const args = process.argv.slice(2); -const valueFlags = new Set(["--registry", "--console", "--parking", "--timeout"]); +const valueFlags = new Set([ + "--registry", "--console", "--parking", "--timeout", "--concurrency", +]); const positional = args.filter((a, i) => !a.startsWith("--") && !valueFlags.has(args[i - 1])); -const name = positional[0]; +const names = positional; +const name = names[0]; if (!name || args.includes("--help")) { console.log(USAGE); process.exit(name ? 0 : 1); } const flag = (n) => args.includes(`--${n}`); @@ -36,11 +40,24 @@ const value = (n, d) => { }; const raw = flag("json"); const timeoutValue = value("timeout", null); +const concurrencyValue = value("concurrency", null); +const jsonError = (error) => names.length === 1 + ? { name, error } + : names.map((requestedName) => ({ name: requestedName, error })); +const printJsonError = (error) => new Promise((resolve, reject) => { + const output = `${JSON.stringify( + jsonError(error), null, names.length > 1 ? 2 : undefined, + )}\n`; + process.stdout.write(output, (writeError) => { + if (writeError) reject(writeError); + else resolve(); + }); +}); for (const option of ["registry", "console", "parking"]) { if (flag(option) && value(option, null) === null) { const error = `--${option} requires a URL`; - if (raw) console.log(JSON.stringify({ name, error })); + if (raw) await printJsonError(error); else console.error(`moshpit-resolve: ${error}`); process.exit(1); } @@ -52,14 +69,19 @@ if (args.includes("--timeout") && ( || Number(timeoutValue) < 1 )) { const error = "--timeout must be a positive integer in milliseconds"; - if (raw) console.log(JSON.stringify({ name, error })); + if (raw) await printJsonError(error); else console.error(`moshpit-resolve: ${error}`); process.exit(1); } -if (!parseRegistryName(name)) { - const error = "not a Moshpit name (one label and one ending)"; - console.log(raw ? JSON.stringify({ name, error }) : `${name} — ${error}`); +if (args.includes("--concurrency") && ( + !/^\d+$/.test(String(concurrencyValue ?? "")) + || !Number.isSafeInteger(Number(concurrencyValue)) + || Number(concurrencyValue) < 1 +)) { + const error = "--concurrency must be a positive integer"; + if (raw) await printJsonError(error); + else console.error(`moshpit-resolve: ${error}`); process.exit(1); } @@ -70,16 +92,85 @@ const config = { parkingBase: value("parking", undefined), timeoutMs: timeoutValue === null ? DEFAULT_LOOKUP_TIMEOUT_MS : Number(timeoutValue), }; -const { - registry: moshpit, decision, destination: url, -} = await resolutionFor(name, flag("clearnet-resolves"), config); -if (raw) { - console.log(JSON.stringify({ name, registry: moshpit, decision, destination: url }, null, 2)); +const resolutions = new Map(); + +async function resolveOne(requestedName) { + const parsed = parseRegistryName(requestedName); + if (!parsed) { + return { + name: requestedName, + error: "not a Moshpit name (one label and one ending)", + }; + } + + const normalized = `${parsed.label}.${parsed.tld}`; + let resolution = resolutions.get(normalized); + if (!resolution) { + resolution = resolutionFor(normalized, flag("clearnet-resolves"), config); + resolutions.set(normalized, resolution); + } + + const { + registry, decision, destination, + } = await resolution; + return { name: requestedName, registry, decision, destination }; +} + +async function mapWithConcurrency(items, limit, mapper) { + const results = new Array(items.length); + let next = 0; + + const workers = Array.from( + { length: Math.min(limit, items.length) }, + async () => { + while (next < items.length) { + const index = next++; + results[index] = await mapper(items[index]); + } + }, + ); + + await Promise.all(workers); + return results; +} + +function printHuman(result) { + if (result.error) { + console.log(`${result.name} — ${result.error}`); + return; + } + + console.log(`${result.name}`); + console.log(` registry ${result.registry ? JSON.stringify(result.registry) : "unreachable"}`); + console.log(` decision ${result.decision.use}`); + console.log(` reason ${result.decision.reason}`); + console.log(` goes to ${result.destination ?? "(nowhere — the browser keeps its own answer)"}`); +} + +const concurrency = concurrencyValue === null + ? DEFAULT_CONCURRENCY + : Number(concurrencyValue); +const results = await mapWithConcurrency(names, concurrency, resolveOne); + +if (names.length === 1) { + const result = results[0]; + if (result.error) { + console.log(raw ? JSON.stringify(result) : `${result.name} — ${result.error}`); + process.exit(1); + } + + if (raw) console.log(JSON.stringify(result, null, 2)); + else printHuman(result); } else { - console.log(`${name}`); - console.log(` registry ${moshpit ? JSON.stringify(moshpit) : "unreachable"}`); - console.log(` decision ${decision.use}`); - console.log(` reason ${decision.reason}`); - console.log(` goes to ${url ?? "(nowhere — the browser keeps its own answer)"}`); + if (raw) { + console.log(JSON.stringify(results, null, 2)); + } else { + results.forEach((result, index) => { + if (index > 0) console.log(""); + printHuman(result); + }); + } + + if (results.some((result) => result.error)) process.exitCode = 1; } diff --git a/lib/index.mjs b/lib/index.mjs index 48ec6cc..d276e77 100644 --- a/lib/index.mjs +++ b/lib/index.mjs @@ -12,6 +12,7 @@ export const DEFAULT_REGISTRY_BASE = 'https://pit.moshcode.sh'; export const DEFAULT_CONSOLE_BASE = 'https://app.moshcode.sh'; +export const DEFAULT_CONCURRENCY = 8; // The one label meaning "manage this namespace" rather than "visit a name". // Reserved, not claimable — otherwise whoever holds `.eggs` could register diff --git a/test/cli.test.mjs b/test/cli.test.mjs index 9aa3103..9779bc9 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -5,9 +5,11 @@ import { createServer } from "node:http"; import test from "node:test"; import { fileURLToPath } from "node:url"; +import { DEFAULT_CONCURRENCY } from "../lib/index.mjs"; + const BIN = fileURLToPath(new URL("../bin/moshpit-resolve.mjs", import.meta.url)); -function run(args) { +function run(args, { stdoutDelayMs = 0 } = {}) { return new Promise((resolve, reject) => { const child = spawn(process.execPath, [BIN, ...args], { stdio: ["ignore", "pipe", "pipe"], @@ -16,10 +18,14 @@ function run(args) { let stderr = ""; child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8"); + if (stdoutDelayMs > 0) child.stdout.pause(); child.stdout.on("data", (chunk) => { stdout += chunk; }); child.stderr.on("data", (chunk) => { stderr += chunk; }); child.on("error", reject); child.on("close", (status) => resolve({ status, stdout, stderr })); + if (stdoutDelayMs > 0) { + setTimeout(() => child.stdout.resume(), stdoutDelayMs); + } }); } @@ -216,3 +222,198 @@ test("--timeout aborts a slow registry lookup", async (t) => { assert.equal(requests, 1); assert.ok(elapsed < 1500, `configured timeout took ${elapsed}ms`); }); + +test("--concurrency rejects invalid values before making a request", async () => { + for (const value of [undefined, "0", "-1", "1.5", "1e3", "nope"]) { + const args = ["blue.eggs", "--concurrency"]; + if (value !== undefined) args.push(value); + const result = await run(args); + + assert.equal(result.status, 1, value); + assert.equal(result.stdout, "", value); + assert.match(result.stderr, /--concurrency must be a positive integer/, value); + } + + const json = await run(["blue.eggs", "--concurrency", "--json"]); + assert.equal(json.status, 1); + assert.equal(json.stderr, ""); + assert.deepEqual(JSON.parse(json.stdout), { + name: "blue.eggs", + error: "--concurrency must be a positive integer", + }); +}); + +test("batch JSON reports global option errors for every requested name", async () => { + const cases = [ + [["--registry"], "--registry requires a URL"], + [["--timeout", "0"], "--timeout must be a positive integer in milliseconds"], + [["--concurrency", "0"], "--concurrency must be a positive integer"], + ]; + + for (const [options, error] of cases) { + const result = await run(["first.eggs", "second.eggs", ...options, "--json"]); + + assert.equal(result.status, 1, error); + assert.equal(result.stderr, "", error); + assert.deepEqual(JSON.parse(result.stdout), [ + { name: "first.eggs", error }, + { name: "second.eggs", error }, + ]); + } + + const names = Array.from({ length: 3000 }, (_, index) => `mosh.t${index}`); + const large = await run( + [...names, "--concurrency", "0", "--json"], + { stdoutDelayMs: 100 }, + ); + const output = JSON.parse(large.stdout); + + assert.equal(large.status, 1); + assert.equal(large.stderr, ""); + assert.equal(output.length, names.length); + assert.deepEqual(output.map(({ name }) => name), names); +}); + +test("batch JSON reports each name in input order and exits non-zero for invalid input", async (t) => { + let requests = 0; + const server = createServer((request, response) => { + requests++; + const name = new URL(request.url, "http://127.0.0.1").searchParams.get("name"); + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ + registered: true, + name_registered: true, + resolved: name, + target: `${name}.target`, + })); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + t.after(() => new Promise((resolve) => server.close(resolve))); + + const result = await run([ + "first.eggs", "localhost", "second.eggs", + "--moshpit", + "--registry", `http://127.0.0.1:${server.address().port}`, + "--json", + ]); + const output = JSON.parse(result.stdout); + + assert.equal(result.status, 1); + assert.equal(result.stderr, ""); + assert.deepEqual(output.map(({ name }) => name), [ + "first.eggs", "localhost", "second.eggs", + ]); + assert.equal(output[0].decision.use, "moshpit"); + assert.deepEqual(output[1], { + name: "localhost", + error: "not a Moshpit name (one label and one ending)", + }); + assert.equal(output[2].decision.use, "moshpit"); + assert.equal(requests, 2); +}); + +test("batch human output preserves order and includes invalid names", async () => { + const result = await run([ + "mosh.eggs", "localhost", "mosh.oranges", + "--console", "https://console.example", + ]); + + assert.equal(result.status, 1); + assert.equal(result.stderr, ""); + assert.equal(result.stdout, `mosh.eggs + registry unreachable + decision register + reason mosh.eggs is the registration console for .eggs + goes to https://console.example/pit?tld=eggs + +localhost — not a Moshpit name (one label and one ending) + +mosh.oranges + registry unreachable + decision register + reason mosh.oranges is the registration console for .oranges + goes to https://console.example/pit?tld=oranges +`); +}); + +test("large batch JSON flushes completely before a non-zero exit", async () => { + const names = Array.from({ length: 300 }, (_, index) => `mosh.t${index}`); + names.splice(150, 0, "localhost"); + + const result = await run([ + ...names, "--console", "https://console.example", "--json", + ]); + const output = JSON.parse(result.stdout); + + assert.equal(result.status, 1); + assert.equal(result.stderr, ""); + assert.equal(output.length, names.length); + assert.deepEqual(output.map(({ name }) => name), names); + assert.deepEqual(output[150], { + name: "localhost", + error: "not a Moshpit name (one label and one ending)", + }); +}); + +test("batch resolution bounds concurrency and coalesces normalized duplicates", async (t) => { + let active = 0; + let maxActive = 0; + const requests = new Map(); + const server = createServer((request, response) => { + const name = new URL(request.url, "http://127.0.0.1").searchParams.get("name"); + requests.set(name, (requests.get(name) || 0) + 1); + active++; + maxActive = Math.max(maxActive, active); + setTimeout(() => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ + registered: true, + name_registered: true, + resolved: name, + target: `${name}.target`, + })); + active--; + }, 40); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + t.after(() => new Promise((resolve) => server.close(resolve))); + + const registry = `http://127.0.0.1:${server.address().port}`; + const names = [ + "one.eggs", "ONE.EGGS.", "two.eggs", "three.eggs", + "four.eggs", "five.eggs", "six.eggs", + ]; + const limited = await run([ + ...names, "--moshpit", "--registry", registry, "--concurrency", "2", "--json", + ]); + + assert.equal(limited.status, 0, limited.stderr || limited.stdout); + assert.equal(limited.stderr, ""); + assert.ok(maxActive > 1, `expected parallel requests, saw ${maxActive}`); + assert.ok(maxActive <= 2, `concurrency limit exceeded: ${maxActive}`); + assert.equal(requests.get("one.eggs"), 1); + assert.equal([...requests.values()].reduce((sum, count) => sum + count, 0), 6); + assert.deepEqual(JSON.parse(limited.stdout).map(({ name }) => name), names); + + active = 0; + maxActive = 0; + requests.clear(); + const defaultNames = Array.from( + { length: DEFAULT_CONCURRENCY + 4 }, + (_, index) => `default${index}.eggs`, + ); + const defaults = await run([ + ...defaultNames, "--moshpit", "--registry", registry, "--json", + ]); + + assert.equal(defaults.status, 0, defaults.stderr || defaults.stdout); + assert.equal(DEFAULT_CONCURRENCY, 8); + assert.ok(maxActive > 1, `expected parallel requests, saw ${maxActive}`); + assert.ok( + maxActive <= DEFAULT_CONCURRENCY, + `default concurrency limit exceeded: ${maxActive}`, + ); + assert.deepEqual(JSON.parse(defaults.stdout).map(({ name }) => name), defaultNames); +});