Enable localhost connections in radicle-explorer

Allow radicle-explorer to connect to a locally running radicle-httpd via
https://app.radicle.xyz/nodes/localhost. This was previously disabled to
encourage use of Radicle Desktop, but is now useful for testing the new
always-on node in local development environments.

Remove support for /nodes/radicle.local, an undocumented and unused convention.

Fix remote node loading when Cloudflare URL-encodes port separator (: → %3A).
This commit is contained in:
Rūdolfs Ošiņš
2026-01-12 14:38:50 -06:00
parent 4c20d626e8
commit c72494f620
22 changed files with 81 additions and 113 deletions
+1
View File
@@ -3,6 +3,7 @@
"fallbackPublicExplorer": "FALLBACK_PUBLIC_EXPLORER",
"requiredApiVersion": "REQUIRED_API_VERSION",
"defaultHttpdPort": "DEFAULT_HTTPD_PORT",
"defaultLocalHttpdPort": "DEFAULT_LOCAL_HTTPD_PORT",
"defaultHttpdScheme": "DEFAULT_HTTPD_SCHEME"
},
"source": {
+1
View File
@@ -3,6 +3,7 @@
"fallbackPublicExplorer": "https://app.radicle.xyz/nodes/$host/$rid$path",
"requiredApiVersion": "~0.18.0",
"defaultHttpdPort": 443,
"defaultLocalHttpdPort": 8080,
"defaultHttpdScheme": "https"
},
"source": {
Vendored
+2
View File
@@ -3,6 +3,8 @@ import type { Config } from "./module.d.ts";
declare global {
// eslint-disable-next-line no-var, @typescript-eslint/naming-convention
var __CONFIG__: Config;
// eslint-disable-next-line no-var, @typescript-eslint/naming-convention
var __PLAYWRIGHT__: boolean;
}
export {};
+2 -2
View File
@@ -1,11 +1,11 @@
import { describe, test } from "vitest";
import { HttpdClient } from "@http-client";
import { defaultHttpdPort } from "@tests/support/fixtures";
import config from "@app/lib/config";
const api = new HttpdClient({
hostname: "127.0.0.1",
port: defaultHttpdPort,
port: config.nodes.defaultHttpdPort,
scheme: "http",
});
+2 -2
View File
@@ -1,18 +1,18 @@
import { describe, test } from "vitest";
import { HttpdClient } from "@http-client";
import config from "@app/lib/config";
import {
aliceMainHead,
aliceRemote,
cobRid,
defaultHttpdPort,
sourceBrowsingRid,
} from "@tests/support/fixtures.js";
describe("repo", () => {
const api = new HttpdClient({
hostname: "127.0.0.1",
port: defaultHttpdPort,
port: config.nodes.defaultHttpdPort,
scheme: "http",
});
+23 -29
View File
@@ -145,37 +145,31 @@ export async function replace(newRoute: Route): Promise<void> {
await navigate("replace", newRoute);
}
function extractBaseUrl(hostAndPort: string): BaseUrl {
if (
hostAndPort === "radicle.local" ||
hostAndPort === `radicle.local:${config.nodes.defaultHttpdPort}` ||
hostAndPort === "0.0.0.0" ||
hostAndPort === `0.0.0.0:${config.nodes.defaultHttpdPort}` ||
hostAndPort === "127.0.0.1" ||
hostAndPort === `127.0.0.1:${config.nodes.defaultHttpdPort}`
) {
return {
hostname: "127.0.0.1",
port: config.nodes.defaultHttpdPort,
scheme: "http",
};
} else if (hostAndPort.includes(":")) {
const [hostname, port] = hostAndPort.split(":");
return {
hostname,
port: Number(port),
scheme:
utils.isLocal(hostname) || utils.isOnion(hostname)
? "http"
: config.nodes.defaultHttpdScheme,
};
export function extractBaseUrl(hostAndPort: string): BaseUrl {
const [hostname, portString] = decodeURIComponent(hostAndPort).split(":");
let port;
if (portString !== undefined) {
port = Number(portString);
} else if (globalThis.__PLAYWRIGHT__ === true) {
port = config.nodes.defaultHttpdPort;
} else {
return {
hostname: hostAndPort,
port: config.nodes.defaultHttpdPort,
scheme: config.nodes.defaultHttpdScheme,
};
port = utils.isLocal(hostname)
? config.nodes.defaultLocalHttpdPort
: config.nodes.defaultHttpdPort;
}
const scheme =
utils.isLocal(hostname) || utils.isOnion(hostname)
? "http"
: config.nodes.defaultHttpdScheme;
return {
hostname,
port,
scheme,
};
}
function urlToRoute(url: URL): Route | null {
+1 -5
View File
@@ -205,11 +205,7 @@ export function isMarkdownPath(path: string): boolean {
// Check whether the given address is a localhost address.
export function isLocal(addr: string): boolean {
return (
addr.startsWith("127.0.0.1") ||
addr.startsWith("0.0.0.0") ||
addr.startsWith("radicle.local")
);
return addr.startsWith("127.0.0.1") || addr.startsWith("localhost");
}
// Check whether the given domain name is an onion domain name.
+2 -6
View File
@@ -13,7 +13,7 @@
selectedSeed,
} from "./SeedSelector";
import { closeFocused } from "@app/components/Popover.svelte";
import { push } from "@app/lib/router";
import { extractBaseUrl, push } from "@app/lib/router";
import DropdownList from "@app/components/DropdownList.svelte";
import DropdownListItem from "@app/components/DropdownList/DropdownListItem.svelte";
@@ -45,11 +45,7 @@
async function navigateToSeed() {
loading = true;
const seed = {
hostname: seedAddressInput.trim(),
port: config.nodes.defaultHttpdPort,
scheme: config.nodes.defaultHttpdScheme,
};
const seed = extractBaseUrl(seedAddressInput.trim());
validationMessage = await validateInput(seed);
if (validationMessage === undefined) {
closeFocused();
+1 -12
View File
@@ -4,7 +4,7 @@ import type { ErrorRoute, NotFoundRoute } from "@app/lib/router/definitions";
import config from "@app/lib/config";
import { HttpdClient } from "@http-client";
import { ResponseError, ResponseParseError } from "@http-client/lib/fetcher";
import { baseUrlToString, isLocal } from "@app/lib/utils";
import { baseUrlToString } from "@app/lib/utils";
import { handleError } from "@app/views/nodes/error";
import { unreachableError } from "@app/views/repos/error";
import { determineSeed } from "./SeedSelector";
@@ -53,17 +53,6 @@ export async function loadNodeRoute(
const api = new HttpdClient(baseUrl);
if (import.meta.env.PROD && isLocal(`${baseUrl.hostname}:${baseUrl.port}`)) {
return {
resource: "error",
params: {
icon: "device",
title: "Local node browsing not supported",
description: `You're trying to access a local node from your browser, we are currently working on a desktop app specific for this use case. Join our <strong>#desktop</strong> channel on <radicle-external-link href="${config.supportWebsite}">${config.supportWebsite}</radicle-external-link> for more information.`,
},
};
}
try {
const [node, stats] = await Promise.all([api.getNode(), api.getStats()]);
+1 -14
View File
@@ -29,7 +29,7 @@ import { HttpdClient } from "@http-client";
import { ResponseError, ResponseParseError } from "@http-client/lib/fetcher";
import { cached } from "@app/lib/cache";
import { handleError, unreachableError } from "@app/views/repos/error";
import { isLocal, unreachable } from "@app/lib/utils";
import { unreachable } from "@app/lib/utils";
import { nodePath } from "@app/views/nodes/router";
export const PATCHES_PER_PAGE = 10;
@@ -262,19 +262,6 @@ export async function loadRepoRoute(
route: RepoRoute,
previousLoaded: LoadedRoute,
): Promise<RepoLoadedRoute | ErrorRoute | NotFoundRoute> {
if (
import.meta.env.PROD &&
isLocal(`${route.node.hostname}:${route.node.port}`)
) {
return {
resource: "error",
params: {
icon: "device",
title: "Local node browsing not supported",
description: `You're trying to access a repository on a local node from your browser, we are currently working on a desktop app specific for this use case. Join our <strong>#desktop</strong> channel on <radicle-external-link href="${config.supportWebsite}">${config.supportWebsite}</radicle-external-link> for more information.`,
},
};
}
const api = new HttpdClient(route.node);
try {
-14
View File
@@ -2,7 +2,6 @@ import type { BaseUrl, NodeIdentity, NodeStats } from "@http-client";
import type { ErrorRoute, NotFoundRoute } from "@app/lib/router/definitions";
import * as utils from "@app/lib/utils";
import config from "@app/lib/config";
import { HttpdClient } from "@http-client";
import { ResponseError, ResponseParseError } from "@http-client/lib/fetcher";
import { handleError } from "@app/views/nodes/error";
@@ -30,19 +29,6 @@ export async function loadUserRoute({
did,
baseUrl,
}: UserRoute): Promise<UserLoadedRoute | NotFoundRoute | ErrorRoute> {
if (
import.meta.env.PROD &&
utils.isLocal(`${baseUrl.hostname}:${baseUrl.port}`)
) {
return {
resource: "error",
params: {
icon: "device",
title: "Local node browsing not supported",
description: `You're trying to access a local node from your browser, we are currently working on a desktop app specific for this use case. Join our <strong>#desktop</strong> channel on <radicle-external-link href="${config.supportWebsite}">${config.supportWebsite}</radicle-external-link> for more information.`,
},
};
}
const parsedDid = utils.parseNodeId(decodeURIComponent(did));
if (!parsedDid) {
return {
+1 -3
View File
@@ -5,7 +5,5 @@ test("exceptions in production build", async ({ page }) => {
// Wait for scripts to finish executing, there might be exceptions that
// happen after the page has been painted.
await page.waitForTimeout(2000);
await expect(
page.getByText("Local node browsing not supported"),
).toBeVisible();
await expect(page.getByText("Node not found")).toBeVisible();
});
+1 -1
View File
@@ -47,7 +47,7 @@ test("copy to clipboard", async ({ page, browserName, context }) => {
await page.getByRole("button", { name: "Git" }).click();
await page.getByText("git clone").locator(".clipboard").first().click();
await expectClipboard(
`git clone http://127.0.0.1/${sourceBrowsingRid.replace(
`git clone http://localhost/${sourceBrowsingRid.replace(
"rad:",
"",
)}.git source-browsing`,
+1 -1
View File
@@ -26,7 +26,7 @@ test("node metadata", async ({ page, peerManager }) => {
});
test("node repos", async ({ page }) => {
await page.goto("/nodes/radicle.local");
await page.goto("/nodes/localhost");
const repo = page
.locator(".repo-card", { hasText: "source-browsing" })
.nth(0);
+2 -2
View File
@@ -24,7 +24,7 @@ test("navigate between landing and repo page", async ({ page }) => {
});
test("navigation between node and repo pages", async ({ page }) => {
await page.goto("/nodes/radicle.local");
await page.goto("/nodes/localhost");
const repo = page
.locator(".repo-card", { hasText: "source-browsing" })
@@ -32,7 +32,7 @@ test("navigation between node and repo pages", async ({ page }) => {
await repo.click();
await expect(page).toHaveURL(sourceBrowsingUrl);
await expectBackAndForwardNavigationWorks("/nodes/radicle.local", page);
await expectBackAndForwardNavigationWorks("/nodes/localhost", page);
await expectUrlPersistsReload(page);
await page.getByRole("link", { name: "Local Node" }).click();
+12
View File
@@ -0,0 +1,12 @@
// Test adapter for configuration in Node.js environment (Playwright tests).
//
// The app uses "virtual:config" (defined in vite.config.ts via
// vite-plugin-virtual), which is populated at build time with
// config.util.toObject() from the "config" npm package. Since Vite's virtual
// module system doesn't work in Node.js, this file provides the same
// configuration data by directly importing and converting the "config" package.
import nodeConfig from "config";
const config = nodeConfig.util.toObject();
export default config;
+10 -4
View File
@@ -30,6 +30,13 @@ export const test = base.extend<{
}>({
forAllTests: [
async ({ outputLog, page }, use) => {
// Flag that tests are running so the app uses the test httpd port
// (8081 from config.test.json) instead of the production default (8080)
// for /nodes/localhost requests.
await page.addInitScript(() => {
globalThis.__PLAYWRIGHT__ = true;
});
const browserLabel = logLabel.logPrefix("browser");
page.on("console", msg => {
// Ignore common console logs that we don't care about.
@@ -602,11 +609,10 @@ export const shortBobHead = formatCommit(bobHead);
export const sourceBrowsingRid = "rad:z4BwwjPCFNVP27FwVbDFgwVwkjcir";
export const cobRid = "rad:z3fpY7nttPPa6MBnAv2DccHzQJnqe";
export const markdownRid = "rad:z2tchH2Ti4LxRKdssPQYs6VHE5rsg";
export const sourceBrowsingUrl = `/nodes/127.0.0.1/${sourceBrowsingRid}`;
export const cobUrl = `/nodes/127.0.0.1/${cobRid}`;
export const markdownUrl = `/nodes/127.0.0.1/${markdownRid}`;
export const sourceBrowsingUrl = `/nodes/localhost/${sourceBrowsingRid}`;
export const cobUrl = `/nodes/localhost/${cobRid}`;
export const markdownUrl = `/nodes/localhost/${markdownRid}`;
export const shortNodeRemote = "z6MktU…1xB22S";
export const defaultHttpdPort = 8081;
export const gitOptions = {
alice: {
GIT_AUTHOR_NAME: "Alice Liddell",
+3 -3
View File
@@ -12,9 +12,9 @@ import {
createCobsFixture,
createMarkdownFixture,
createSourceBrowsingFixture,
defaultHttpdPort,
gitOptions,
} from "@tests/support/fixtures.js";
import config from "@tests/support/config.js";
import { createPeerManager } from "@tests/support/peerManager.js";
const heartwoodBinaryPath = Path.join(
@@ -87,7 +87,7 @@ export default async function globalSetup(): Promise<() => void> {
alias: "palm",
},
});
await palm.startHttpd(defaultHttpdPort);
await palm.startHttpd(config.nodes.defaultHttpdPort);
try {
console.log("Creating source-browsing fixture");
@@ -108,7 +108,7 @@ export default async function globalSetup(): Promise<() => void> {
}
await palm.stopNode();
} else {
await palm.startHttpd(defaultHttpdPort);
await palm.startHttpd(config.nodes.defaultHttpdPort);
}
return async () => {
+4 -4
View File
@@ -1,6 +1,6 @@
import { defaultHttpdPort } from "@tests/support/fixtures.js";
import { describe, expect, test } from "vitest";
import { testExports, type Route } from "@app/lib/router";
import config from "@tests/support/config.js";
// Defining the window.origin value, since vitest doesn't provide one.
window.origin = "http://localhost:3000";
@@ -217,7 +217,7 @@ describe("pathToRoute", () => {
baseUrl: {
hostname: "example.node.tld",
scheme: "http",
port: defaultHttpdPort,
port: config.nodes.defaultHttpdPort,
},
repoPageIndex: 0,
},
@@ -232,7 +232,7 @@ describe("pathToRoute", () => {
node: {
hostname: "example.node.tld",
scheme: "http",
port: defaultHttpdPort,
port: config.nodes.defaultHttpdPort,
},
repo: "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT",
route: "",
@@ -248,7 +248,7 @@ describe("pathToRoute", () => {
node: {
hostname: "example.node.tld",
scheme: "http",
port: defaultHttpdPort,
port: config.nodes.defaultHttpdPort,
},
repo: "rad:zKtT7DmF9H34KkvcKj9PHW19WzjT",
route: "",
+3 -3
View File
@@ -2,7 +2,7 @@ import { test, expect } from "@tests/support/fixtures.js";
test("node page", async ({ page }) => {
await page.clock.setFixedTime(new Date("November 24 2022 12:00:00"));
await page.goto("/nodes/radicle.local", { waitUntil: "networkidle" });
await page.goto("/nodes/localhost", { waitUntil: "networkidle" });
await expect(page).toHaveScreenshot();
});
@@ -38,7 +38,7 @@ test("response parse error", async ({ page }) => {
});
});
await page.goto("/nodes/radicle.local", {
await page.goto("/nodes/localhost", {
waitUntil: "networkidle",
});
await expect(page).toHaveScreenshot();
@@ -51,7 +51,7 @@ test("response error", async ({ page }) => {
});
});
await page.goto("/nodes/radicle.local", {
await page.goto("/nodes/localhost", {
waitUntil: "networkidle",
});
await expect(page).toHaveScreenshot();
+4 -4
View File
@@ -7,14 +7,14 @@ import {
test("user page", async ({ page }) => {
await page.clock.setFixedTime(new Date("November 24 2022 12:00:00"));
await page.goto(`/nodes/radicle.local/users/${aliceRemote}`, {
await page.goto(`/nodes/localhost/users/${aliceRemote}`, {
waitUntil: "networkidle",
});
await expect(page).toHaveScreenshot();
});
test("empty pinned repos", async ({ page }) => {
await page.goto(`/nodes/radicle.local/users/${bobRemote}`, {
await page.goto(`/nodes/localhost/users/${bobRemote}`, {
waitUntil: "networkidle",
});
await expect(page).toHaveScreenshot();
@@ -27,7 +27,7 @@ test("response parse error", async ({ page }) => {
});
});
await page.goto(`/nodes/radicle.local/users/${bobRemote}`, {
await page.goto(`/nodes/localhost/users/${bobRemote}`, {
waitUntil: "networkidle",
});
await expect(page).toHaveScreenshot();
@@ -40,7 +40,7 @@ test("response error", async ({ page }) => {
});
});
await page.goto(`/nodes/radicle.local/users/${bobRemote}`, {
await page.goto(`/nodes/localhost/users/${bobRemote}`, {
waitUntil: "networkidle",
});
await expect(page).toHaveScreenshot();
+4 -4
View File
@@ -7,14 +7,14 @@ import {
test("user page", async ({ page }) => {
await page.clock.setFixedTime(new Date("November 24 2022 12:00:00"));
await page.goto(`/nodes/radicle.local/users/${aliceRemote}`, {
await page.goto(`/nodes/localhost/users/${aliceRemote}`, {
waitUntil: "networkidle",
});
await expect(page).toHaveScreenshot();
});
test("empty pinned repos", async ({ page }) => {
await page.goto(`/nodes/radicle.local/users/${bobRemote}`, {
await page.goto(`/nodes/localhost/users/${bobRemote}`, {
waitUntil: "networkidle",
});
await expect(page).toHaveScreenshot();
@@ -27,7 +27,7 @@ test("response parse error", async ({ page }) => {
});
});
await page.goto(`/nodes/radicle.local/users/${bobRemote}`, {
await page.goto(`/nodes/localhost/users/${bobRemote}`, {
waitUntil: "networkidle",
});
await expect(page).toHaveScreenshot();
@@ -40,7 +40,7 @@ test("response error", async ({ page }) => {
});
});
await page.goto(`/nodes/radicle.local/users/${bobRemote}`, {
await page.goto(`/nodes/localhost/users/${bobRemote}`, {
waitUntil: "networkidle",
});
await expect(page).toHaveScreenshot();