feat: support path-only hrefs (upstream sync 1.59.3 → 1.60.0+)#2
Merged
Conversation
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
added a commit
that referenced
this pull request
May 12, 2026
…indicator fix(lint): unblock main CI after PR #2 merge
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 underdocs/halos/learnings/.feat(app): support path-only hrefs for multi-hostname access— upstream-bound.appHrefSchemaaccepts 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. NewRenderablePathclass lets integrations build hrefs from path-only externalUrl bases without crashingnew 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
Followups (not in scope)