From c72494f6208b371f5a2438d47dfdceb4693cfae1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C5=ABdolfs=20O=C5=A1i=C5=86=C5=A1?= Date: Fri, 9 Jan 2026 14:10:20 -0600 Subject: [PATCH] Enable localhost connections in radicle-explorer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- config/custom-environment-variables.json | 1 + config/default.json | 1 + global.d.ts | 2 + http-client/tests/client.test.ts | 4 +- http-client/tests/repo.test.ts | 4 +- src/lib/router.ts | 52 +++++++++++------------- src/lib/utils.ts | 6 +-- src/views/nodes/SeedSelector.svelte | 8 +--- src/views/nodes/router.ts | 13 +----- src/views/repos/router.ts | 15 +------ src/views/users/router.ts | 14 ------- tests/build/smoke.spec.ts | 4 +- tests/e2e/clipboard.spec.ts | 2 +- tests/e2e/node.spec.ts | 2 +- tests/e2e/router.ts | 4 +- tests/support/config.ts | 12 ++++++ tests/support/fixtures.ts | 14 +++++-- tests/support/globalSetup.ts | 6 +-- tests/unit/router.test.ts | 8 ++-- tests/visual/desktop/node.spec.ts | 6 +-- tests/visual/desktop/user.spec.ts | 8 ++-- tests/visual/mobile/user.spec.ts | 8 ++-- 22 files changed, 81 insertions(+), 113 deletions(-) create mode 100644 tests/support/config.ts diff --git a/config/custom-environment-variables.json b/config/custom-environment-variables.json index 6d60043a7..48ac0f83d 100644 --- a/config/custom-environment-variables.json +++ b/config/custom-environment-variables.json @@ -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": { diff --git a/config/default.json b/config/default.json index 126dc6697..80277991c 100644 --- a/config/default.json +++ b/config/default.json @@ -3,6 +3,7 @@ "fallbackPublicExplorer": "https://app.radicle.xyz/nodes/$host/$rid$path", "requiredApiVersion": "~0.18.0", "defaultHttpdPort": 443, + "defaultLocalHttpdPort": 8080, "defaultHttpdScheme": "https" }, "source": { diff --git a/global.d.ts b/global.d.ts index 252d60211..6b4cef961 100644 --- a/global.d.ts +++ b/global.d.ts @@ -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 {}; diff --git a/http-client/tests/client.test.ts b/http-client/tests/client.test.ts index 02ebd1614..6d37e4160 100644 --- a/http-client/tests/client.test.ts +++ b/http-client/tests/client.test.ts @@ -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", }); diff --git a/http-client/tests/repo.test.ts b/http-client/tests/repo.test.ts index c56a6987d..0e01feb9c 100644 --- a/http-client/tests/repo.test.ts +++ b/http-client/tests/repo.test.ts @@ -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", }); diff --git a/src/lib/router.ts b/src/lib/router.ts index e0864c4ab..285d1e61a 100644 --- a/src/lib/router.ts +++ b/src/lib/router.ts @@ -145,37 +145,31 @@ export async function replace(newRoute: Route): Promise { 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 { diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 36b0150ef..5c5c3eb87 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -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. diff --git a/src/views/nodes/SeedSelector.svelte b/src/views/nodes/SeedSelector.svelte index 1201d3dfc..394030f26 100644 --- a/src/views/nodes/SeedSelector.svelte +++ b/src/views/nodes/SeedSelector.svelte @@ -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(); diff --git a/src/views/nodes/router.ts b/src/views/nodes/router.ts index 26f9b22eb..74cb74d0f 100644 --- a/src/views/nodes/router.ts +++ b/src/views/nodes/router.ts @@ -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 #desktop channel on ${config.supportWebsite} for more information.`, - }, - }; - } - try { const [node, stats] = await Promise.all([api.getNode(), api.getStats()]); diff --git a/src/views/repos/router.ts b/src/views/repos/router.ts index 0a639fd34..e1174fcea 100644 --- a/src/views/repos/router.ts +++ b/src/views/repos/router.ts @@ -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 { - 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 #desktop channel on ${config.supportWebsite} for more information.`, - }, - }; - } const api = new HttpdClient(route.node); try { diff --git a/src/views/users/router.ts b/src/views/users/router.ts index 5acee62e8..f9fdfda66 100644 --- a/src/views/users/router.ts +++ b/src/views/users/router.ts @@ -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 { - 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 #desktop channel on ${config.supportWebsite} for more information.`, - }, - }; - } const parsedDid = utils.parseNodeId(decodeURIComponent(did)); if (!parsedDid) { return { diff --git a/tests/build/smoke.spec.ts b/tests/build/smoke.spec.ts index 7adf93a11..b1216c5a8 100644 --- a/tests/build/smoke.spec.ts +++ b/tests/build/smoke.spec.ts @@ -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(); }); diff --git a/tests/e2e/clipboard.spec.ts b/tests/e2e/clipboard.spec.ts index b36e11d69..82d5b080e 100644 --- a/tests/e2e/clipboard.spec.ts +++ b/tests/e2e/clipboard.spec.ts @@ -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`, diff --git a/tests/e2e/node.spec.ts b/tests/e2e/node.spec.ts index 8c65e05d9..daf5fd5a3 100644 --- a/tests/e2e/node.spec.ts +++ b/tests/e2e/node.spec.ts @@ -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); diff --git a/tests/e2e/router.ts b/tests/e2e/router.ts index 39f5cd503..472dc5cb1 100644 --- a/tests/e2e/router.ts +++ b/tests/e2e/router.ts @@ -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(); diff --git a/tests/support/config.ts b/tests/support/config.ts new file mode 100644 index 000000000..13251c667 --- /dev/null +++ b/tests/support/config.ts @@ -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; diff --git a/tests/support/fixtures.ts b/tests/support/fixtures.ts index d2d5a875d..c8a991d3c 100644 --- a/tests/support/fixtures.ts +++ b/tests/support/fixtures.ts @@ -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", diff --git a/tests/support/globalSetup.ts b/tests/support/globalSetup.ts index ce2fb1673..0435b98f0 100644 --- a/tests/support/globalSetup.ts +++ b/tests/support/globalSetup.ts @@ -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 () => { diff --git a/tests/unit/router.test.ts b/tests/unit/router.test.ts index d9f645632..e220f816b 100644 --- a/tests/unit/router.test.ts +++ b/tests/unit/router.test.ts @@ -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: "", diff --git a/tests/visual/desktop/node.spec.ts b/tests/visual/desktop/node.spec.ts index e2517eff6..22223a898 100644 --- a/tests/visual/desktop/node.spec.ts +++ b/tests/visual/desktop/node.spec.ts @@ -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(); diff --git a/tests/visual/desktop/user.spec.ts b/tests/visual/desktop/user.spec.ts index c930f3270..b5addb0b5 100644 --- a/tests/visual/desktop/user.spec.ts +++ b/tests/visual/desktop/user.spec.ts @@ -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(); diff --git a/tests/visual/mobile/user.spec.ts b/tests/visual/mobile/user.spec.ts index c930f3270..b5addb0b5 100644 --- a/tests/visual/mobile/user.spec.ts +++ b/tests/visual/mobile/user.spec.ts @@ -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();