From 4ef33c43bc2d7ef6cb326427ca46a18eefa99590 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 22 May 2026 21:15:59 +0000 Subject: [PATCH] impl(web): configurable Connection abstraction (tauri port phase 2) Phase 2 of the Tauri port plan. The frontend used to hardcode same-origin when constructing the RPC client and /files/* URLs. With Tauri (and a 'point my browser at a remote forager' use-case for the web build) on the horizon, both need to be resolved from a runtime Connection. new: packages/web/src/lib/connection.ts - resolve_connection() picks a base_url in this priority order: 1. window.__FORAGER_CONNECTION__ (Tauri injects this at startup) 2. ?api=https://example.com query string 3. Same origin (current default) - Result is cached per session. - Trailing slashes stripped for predictable URL building. new: packages/web/test/connection.test.ts - 8 unit tests covering each branch + caching + SSR placeholder fallback. BaseController and Rune - Both accept an optional Connection (default = resolve_connection()). - Both expose media_url(id) and thumbnail_url(id, index?) helpers so the rest of the SPA never builds those URLs by hand. - BaseController's RPC client URL now derives from connection.base_url. callers - lib/runes/media_view_rune.svelte.ts: preview_thumbnail now uses this.thumbnail_url(...) on both MediaViewRune and MediaGroupRune. - routes/browse/components/MediaView.svelte: media_url, audio thumbnail src, and the filmstrip thumbnails all go through controller.{media, thumbnail}_url(...). Browser experience is identical for the same-origin default path. The two new branches are exercised by the unit tests; visual smoke-tested in a Chromium webview. Co-authored-by: andykais-claude --- packages/web/src/lib/base_controller.ts | 23 +++- packages/web/src/lib/connection.ts | 65 ++++++++++ .../src/lib/runes/media_view_rune.svelte.ts | 6 +- packages/web/src/lib/runes/rune.ts | 22 +++- .../routes/browse/components/MediaView.svelte | 6 +- packages/web/test/connection.test.ts | 115 ++++++++++++++++++ 6 files changed, 227 insertions(+), 10 deletions(-) create mode 100644 packages/web/src/lib/connection.ts create mode 100644 packages/web/test/connection.test.ts diff --git a/packages/web/src/lib/base_controller.ts b/packages/web/src/lib/base_controller.ts index b221163e..8b9745b5 100644 --- a/packages/web/src/lib/base_controller.ts +++ b/packages/web/src/lib/base_controller.ts @@ -1,7 +1,7 @@ -import * as svelte from 'svelte' import type {ApiSpec} from '$lib/api.ts' import * as rpc from '@andykais/ts-rpc/client.ts' import type { Config } from '$lib/server/config.ts' +import { resolve_connection, type Connection } from '$lib/connection.ts' import {create_focuser, SettingsRune} from '$lib/runes/index.ts' import {Keybinds} from '$lib/keybinds.ts' @@ -9,6 +9,7 @@ import {Keybinds} from '$lib/keybinds.ts' abstract class BaseController { client: ReturnType> keybinds: Keybinds + connection: Connection #config: Config | undefined abstract runes: { @@ -16,12 +17,28 @@ abstract class BaseController { settings: SettingsRune } - constructor(config: Config) { + constructor(config: Config, connection: Connection = resolve_connection()) { this.#config = config - this.client = rpc.create(`${window.location.protocol}${window.location.host}/rpc/:signature`) + this.connection = connection + this.client = rpc.create(`${connection.base_url}/rpc/:signature`) this.keybinds = new Keybinds(config) } + /** URL for streaming a media file from the configured Forager server. */ + media_url(media_reference_id: number): string { + return `${this.connection.base_url}/files/media_file/${media_reference_id}` + } + + /** + * URL for a thumbnail. `index` is appended as a query-string when non-zero + * for parity with the existing route shape; the server's behaviour for + * `index` is unchanged from before phase 2. + */ + thumbnail_url(thumbnail_id: number | undefined, index: number = 0): string { + const base = `${this.connection.base_url}/files/thumbnail/${thumbnail_id}` + return index === 0 ? base : `${base}?index=${index}` + } + get config() { if (this.#config) return this.#config else throw new Error(`Controller::config not initialized`) diff --git a/packages/web/src/lib/connection.ts b/packages/web/src/lib/connection.ts new file mode 100644 index 00000000..a7119cfa --- /dev/null +++ b/packages/web/src/lib/connection.ts @@ -0,0 +1,65 @@ +/** + * Connection abstraction for talking to a Forager server. + * + * The SPA used to assume same-origin everywhere. With the Tauri shell on + * the horizon (and a "point my browser at a remote forager" use-case for + * the web build), we now resolve a base URL at boot time from three + * sources, in priority order: + * + * 1. `window.__FORAGER_CONNECTION__` โ€” injected by the Tauri shell + * before the SPA starts up (see the Tauri port plan ยง7). + * 2. `?api=https://example.com` query-string โ€” handy for the + * browser-served build when you want to point a hosted SPA at a + * different backend. + * 3. Same origin โ€” the current default. + * + * Auth is intentionally out of scope per the design doc; the Connection + * carries only a `base_url`. + */ + +export interface Connection { + base_url: string +} + +declare global { + interface Window { + __FORAGER_CONNECTION__?: { base_url: string } + } +} + +function strip_trailing_slash(s: string): string { + return s.endsWith('/') ? s.slice(0, -1) : s +} + +let cached: Connection | undefined + +export function resolve_connection(): Connection { + if (cached) return cached + + if (typeof window === 'undefined') { + // SSR / prerender / test contexts. We never actually render against a + // real backend here, so a placeholder is fine. + cached = { base_url: '' } + return cached + } + + const injected = window.__FORAGER_CONNECTION__ + if (injected?.base_url) { + cached = { base_url: strip_trailing_slash(injected.base_url) } + return cached + } + + const url_param = new URL(window.location.href).searchParams.get('api') + if (url_param) { + cached = { base_url: strip_trailing_slash(url_param) } + return cached + } + + cached = { base_url: window.location.origin } + return cached +} + +/** Reset the cached connection. Intended for tests; not used at runtime. */ +export function __reset_connection_for_tests() { + cached = undefined +} diff --git a/packages/web/src/lib/runes/media_view_rune.svelte.ts b/packages/web/src/lib/runes/media_view_rune.svelte.ts index 3950ea90..cdeebf46 100644 --- a/packages/web/src/lib/runes/media_view_rune.svelte.ts +++ b/packages/web/src/lib/runes/media_view_rune.svelte.ts @@ -45,7 +45,9 @@ export class MediaViewRune extends Rune { } get preview_thumbnail(): string | undefined { - return `/files/thumbnail/${this.media.thumbnails.results.at(0)?.id}` + const thumbnail = this.media.thumbnails.results.at(0) + if (thumbnail === undefined) return undefined + return this.thumbnail_url(thumbnail.id) } get thumbnails() { @@ -185,7 +187,7 @@ export class MediaGroupRune extends MediaViewRune { get preview_thumbnail(): string | undefined { const media_entry = this.grouped_state.media_list.at(0) if (media_entry) { - return `/files/thumbnail/${media_entry.thumbnails.results[0].id}` + return this.thumbnail_url(media_entry.thumbnails.results[0].id) } return undefined } diff --git a/packages/web/src/lib/runes/rune.ts b/packages/web/src/lib/runes/rune.ts index 82c79666..46489c6f 100644 --- a/packages/web/src/lib/runes/rune.ts +++ b/packages/web/src/lib/runes/rune.ts @@ -1,6 +1,24 @@ import type { BaseController } from '$lib/base_controller.ts' +import { resolve_connection, type Connection } from '$lib/connection.ts' export class Rune { - public constructor(protected client: BaseController['client']) {} -} + protected connection: Connection + + public constructor( + protected client: BaseController['client'], + connection: Connection = resolve_connection(), + ) { + this.connection = connection + } + /** URL for streaming a media file. Mirrors `BaseController.media_url`. */ + protected media_url(media_reference_id: number): string { + return `${this.connection.base_url}/files/media_file/${media_reference_id}` + } + + /** URL for a thumbnail. Mirrors `BaseController.thumbnail_url`. */ + protected thumbnail_url(thumbnail_id: number | undefined, index: number = 0): string { + const base = `${this.connection.base_url}/files/thumbnail/${thumbnail_id}` + return index === 0 ? base : `${base}?index=${index}` + } +} diff --git a/packages/web/src/routes/browse/components/MediaView.svelte b/packages/web/src/routes/browse/components/MediaView.svelte index 9f6ac754..8340ee1d 100644 --- a/packages/web/src/routes/browse/components/MediaView.svelte +++ b/packages/web/src/routes/browse/components/MediaView.svelte @@ -60,7 +60,7 @@ if (media_selections.current_selection.media_response?.media_type !== 'media_file') { return } - return `/files/media_file/${media_selections.current_selection.media_response.media_reference.id}` + return controller.media_url(media_selections.current_selection.media_response.media_reference.id) }) $effect(() => { if (media_url) { @@ -111,14 +111,14 @@
{#each media_selections.current_selection.thumbnails.results as thumbnail, index}
- Thumbnail {index}
+ Thumbnail {index}
{/each} {/if} {:else if media_selections.current_selection.media_response.media_file.media_type === 'AUDIO'} Audio thumbnail + src={controller.thumbnail_url(media_selections.current_selection.media_response.media_reference.id)} alt="Audio thumbnail">