Skip to content

feat: support path-only hrefs (upstream sync 1.59.3 → 1.60.0+)#2

Merged
mairas merged 4 commits into
mainfrom
feat/path-only-app-hrefs
May 12, 2026
Merged

feat: support path-only hrefs (upstream sync 1.59.3 → 1.60.0+)#2
mairas merged 4 commits into
mainfrom
feat/path-only-app-hrefs

Conversation

@mairas
Copy link
Copy Markdown

@mairas mairas commented May 12, 2026

Brings the path-only-href support our upstream PR (homarr-labs#5595) is iterating on, plus the routine upstream sync from 1.59.3 to the most recent 1.60.0-era state.

What's in this PR

Two distinct layers:

1. Upstream sync (commits authored upstream, 1.59.3 → 1.60.0)

36 commits from upstream covering dependency bumps, Crowdin translation syncs, the 1.60.0 release commits, the recent docker/podman compatibility work, and CI tweaks. These are direct cherry-picks of upstream history — no fork-side edits.

2. Path-only href feature (4 fork-side commits)

  • ci(fork): add hatlabs fork docker image build pipeline — pre-existing fork infrastructure, unchanged.
  • docs(fork): document upstream-bound commit hygiene and widget error pattern — FORK.md convention + the widget-error-handling learning captured under docs/halos/learnings/.
  • feat(app): support path-only hrefs for multi-hostname access — upstream-bound. appHrefSchema accepts path-only hrefs (e.g. /cockpit/) in addition to absolute URLs; new resolver + ping CONFLICT path for path-only; orange PingDot for the no-pingUrl case; bookmarks sub-label helper; translations across all locales.
  • feat(integrations): support path-only externalUrl via RenderablePath — upstream-bound. New RenderablePath class lets integrations build hrefs from path-only externalUrl bases without crashing new URL, so Jellyfin/Emby/Sonarr/Radarr/Overseerr links resolve against the user's current origin too.

The two upstream-bound commits mirror the cleaned chain on the upstream PR head (hatlabs/upstream/feat/path-only-app-hrefs), modulo base drift.

Why now

The upstream PR (homarr-labs#5595) is in active review with @Meierschlumpf; even when it merges, upstream's release cadence means there's a non-trivial gap before it lands in a homarr release we'd pull. Shipping the feature in the next HaLOS fork container build avoids that wait — the multi-hostname-href problem is the original motivation for the PR and we want HaLOS devices in the field to benefit immediately.

Merge strategy

Merge with a merge commit (do not squash) — preserves the four fork-side commits per FORK.md commit hygiene: fork-only commits separate from upstream-bound commits, so future upstream cherry-picks see a contiguous range.

Test plan

  • `pnpm vitest run` on changed scope (validation, common/url, sub-label, app router, base integration) — 77 pass.
  • `pnpm turbo typecheck` on affected packages — clean.
  • Prettier — clean.
  • Verify path-only-bound app card renders correctly in the dashboard (manual smoke on a HaLOS device after the next fork container release).
  • Verify Jellyfin/Sonarr integration links resolve against the current origin under path-only externalUrl (manual smoke).

Followups (not in scope)

mairas added 4 commits May 4, 2026 10:31
Adds a fork-specific image-build workflow that publishes multi-arch
images to ghcr.io/hatlabs/homarr on tag push (v*-halos.*) or manual
dispatch, and guards the upstream release/weekly-release workflows so
they no-op on the fork.

Tag scheme and workflow behavior are documented in FORK.md.
…attern

Fork-only docs files. Not part of any upstream cherry-pick range.

- FORK.md: clarify the convention for upstream-bound fork branches —
  fork-only files (FORK.md itself, deployment-fork-image.yml, the
  upstream-workflow guards, docs/halos/) must live in dedicated
  commits separate from upstream-bound changes so the eventual
  upstream PR can cherry-pick a contiguous upstream-bound range
  without file-level surgery. Convention: one leading commit
  (ci(fork): ...) carries every fork-only file; subsequent commits
  carry the upstream-bound changes.

- docs/halos/learnings/2026-04-30-widget-conditional-trpc-error-handling.md:
  capture the useSuspenseQuery + ErrorBoundary + Suspense pattern used
  in PingIndicator for path-only-href apps. CONFLICT errors (path-only
  without explicit pingUrl) render an indeterminate orange dot inline;
  other tRPC errors propagate to the widget-level boundary as usual.
  Documents the choice between local error handling vs. propagation
  so a future contributor knows which idiom to reach for.
App hrefs now accept the path-only form (e.g. "/cockpit/") in addition
to absolute URLs. Path-only hrefs resolve against the current origin in
the browser, which lets a single dashboard work across multiple
hostnames (mDNS, VPN FQDN, DHCP DNS) without baking a hostname into
each card at registration time.

Path-only hrefs intentionally resolve to null server-side in the ping
widget. They are a browser-resolved form: the dashboard renders
<a href="/cockpit/"> and the browser navigates against the user's
current origin. The server has no canonical hostname for an app whose
href is path-only and must not synthesize one from request headers
(that would be a header-spoofing / SSRF vector). Apps that need
server-side ping coverage under multi-hostname deployments should set
an explicit pingUrl.

- packages/validation/src/app.ts: appHrefSchema accepts absolute URL
  OR path-only form. Path-only rejects backslash, JS \s whitespace,
  C0/C1 controls, and Unicode zero-width / bidi / formatting characters
  anywhere in the path (display-spoofing protection). Rejects
  javascript:, protocol-relative "//host/...", single-slash root,
  consecutive slashes mid-path ("/foo//bar"), and bare strings. Both
  rejection branches (character class + consecutive slashes) route
  through createCustomErrorParams with translated keys
  appHrefInvalid and appHrefConsecutiveSlashes.

- packages/common/src/url.ts: new resolveServerUrl(app) helper
  centralises the pingUrl-or-absolute-href-or-null pattern used by the
  ping endpoint. Path-only hrefs without an explicit pingUrl resolve
  to null.

- packages/api/src/router/widgets/app.ts: path-only hrefs without an
  explicit pingUrl produce a CONFLICT tRPC response from the ping
  router (the server cannot ping a hostless URL).

- packages/widgets/src/app/{component,ping/ping-indicator}.tsx:
  PingIndicator uses useSuspenseQuery wrapped in a local
  ErrorBoundary + Suspense inside the file. The fallback handles the
  CONFLICT case inline (orange IconLoader dot signalling
  "ping not configured") and re-throws other tRPC errors so the
  widget-level boundary still handles them as before.

- packages/widgets/src/bookmarks/sub-label.ts: extracted
  getHrefSubLabel helper. Absolute -> host; path-only -> trailing-
  slash-trimmed path; null -> empty. Branch-free given schema-
  validated inputs.

- packages/translation/src/lang/*.json: new keys appHrefInvalid and
  appHrefConsecutiveSlashes added under common.zod.errors.custom across
  every locale. Languages where adjacent custom-error keys already
  carry full translations receive a translation; languages where
  adjacent keys are empty Crowdin placeholders receive empty
  placeholders so Crowdin can fill them in later. cr.json (Crowdin
  internal pseudolanguage) is intentionally skipped because its
  crwdns/crwdne marker format uses Crowdin-internal IDs we cannot
  synthesize.

- packages/widgets/package.json: react-error-boundary added from the
  workspace catalog (already in use by apps/nextjs).

Tests:

- 33 schema cases (acceptance + rejection inc. backslash, whitespace,
  control chars, zero-width / bidi, consecutive slashes).
- 11 resolveServerUrl cases.
- 10 sub-label cases.
- 3 router-level ping cases (path-only-without-pingUrl CONFLICT,
  path-only-with-pingUrl, absolute-href passthrough).

Backward compatibility: every change is additive. Existing absolute-
URL hrefs validate identically and render byte-identically. Path-only
support is a new branch that activates only for newly-shaped data.
Integrations build hrefs from `integration.externalUrl ?? integration.url`
via `createUrl()`. With path-only app hrefs now possible, externalUrl
can arrive as "/cockpit/" — which crashes `new URL` server-side.

This commit teaches the integration helpers to handle path-only bases
so the integration-rendered hrefs (Jellyfin "Watch movie", Sonarr
"Series", Radarr "Movie", Overseerr request links, etc.) resolve
against the user's current origin too, matching the multi-hostname
semantics the dashboard cards already enjoy.

- packages/integrations/src/base/integration.ts: new `RenderablePath`
  class mirrors just enough of the WHATWG URL surface (`toString`,
  `pathname`, `hostname`, `searchParams`) for the 19 existing
  `super.externalUrl(...)` caller sites. 17 callers immediately call
  `.toString()` and the two outliers (`ntfy` reads `.hostname` and
  `.pathname` for a fallback title; `overseerr` returns the value up
  to a `.toString()` caller in `constructAvatarUrl`) keep behaving
  sensibly for path-only.
- `externalUrl()` branches on
  `base.startsWith("/") && !base.startsWith("//")` and returns a
  `RenderablePath` for path-only bases or a `URL` for absolute bases
  (existing behavior). Scheme-relative bases are rejected at the
  schema layer; the explicit `!startsWith("//")` guard here is
  defense-in-depth so a malformed value can't cross-origin-escape
  through the path-only branch.
- Fragment-handling invariant documented on the class. The
  constructor splits on the first `?` only, not on `#`. This is
  intentional: Jellyfin and Emby pass hash-bang routes like
  `/web/index.html#!/details?id=abc` and expect the post-`?` params
  to stay inside the hash. WHATWG URL would split them out into
  `.hash` separately; mirroring that behavior would break the SPA-
  routing callers this method exists to serve.

- packages/integrations/test/base.spec.ts: six new tests cover
  absolute and path-only externalUrl, query-param merging in both
  branches, path-embedded query strings combined with extra
  queryParams for path-only, and null-externalUrl fallback to
  `integration.url`. `FakeIntegration` extended with a
  `callExternalUrl` helper that exposes the protected `externalUrl`
  method.

The integration middleware (`packages/api/src/middlewares/integration.ts`)
keeps the existing `externalUrl: rest.app?.href ?? null` shape — path-
only hrefs now flow through cleanly to integrations and the new
`RenderablePath` branch handles them. The same fix automatically covers
the pre-existing pattern in
`packages/request-handler/src/lib/cached-request-integration-job-handler.ts`.

`resolveServerUrl` stays in `packages/api/src/router/widgets/app.ts`
only — the ping endpoint genuinely cannot ping a hostless URL, so
CONFLICT remains the right signal there.
@mairas mairas merged commit 0d283d8 into main May 12, 2026
1 check passed
mairas added a commit that referenced this pull request May 12, 2026
…indicator

fix(lint): unblock main CI after PR #2 merge
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant