diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index d8a567a230..b1a4d1b649 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -1,4 +1,12 @@ -import { ApiKeyResponseDto, getMyApiKey, getMyUser, init, isHttpError, Permission } from '@immich/sdk'; +import { + ApiKeyResponseDto, + getMyApiKey, + getMyUser, + init, + isHttpError, + isMalformedResponseError, + Permission, +} from '@immich/sdk'; import { convertPathToPattern, glob } from 'fast-glob'; import { createHash } from 'node:crypto'; import { createReadStream } from 'node:fs'; @@ -82,7 +90,7 @@ export const connect = async (url: string, key: string) => { init({ baseUrl: url, apiKey: key }); const [error] = await withError(getMyUser()); - if (isHttpError(error)) { + if (isHttpError(error) || isMalformedResponseError(error)) { logError(error, `Failed to connect to server ${url}`); process.exit(1); } @@ -94,6 +102,11 @@ export const logError = (error: unknown, message: string) => { if (isHttpError(error)) { console.error(`${message}: ${error.status}`); console.error(JSON.stringify(error.data, undefined, 2)); + } else if (isMalformedResponseError(error)) { + console.error(`${message}: ${error.message}`); + console.error( + 'Check that the URL points at the Immich API, and that nothing in front of it (reverse proxy, SSO portal) is answering instead.', + ); } else { console.error(`${message} - ${error}`); } diff --git a/packages/sdk/src/fetch-errors.ts b/packages/sdk/src/fetch-errors.ts index 306710fb8b..05fb427e6d 100644 --- a/packages/sdk/src/fetch-errors.ts +++ b/packages/sdk/src/fetch-errors.ts @@ -20,3 +20,24 @@ export interface ApiHttpError extends HttpError { export function isHttpError(error: unknown): error is ApiHttpError { return error instanceof HttpError; } + +export class MalformedResponseError extends Error { + override name = 'MalformedResponseError'; + + constructor( + reason: string, + readonly url: string, + readonly status: number, + readonly contentType: string | null, + ) { + super( + `${reason} (${url}, HTTP ${status}, content-type: ${contentType ?? 'none'})`, + ); + } +} + +export function isMalformedResponseError( + error: unknown, +): error is MalformedResponseError { + return error instanceof MalformedResponseError; +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 7adbca4d7e..779376a4fd 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1,4 +1,5 @@ import { defaults } from './fetch-client.js'; +import { MalformedResponseError } from './fetch-errors.js'; export * from './fetch-client.js'; export * from './fetch-errors.js'; @@ -48,6 +49,37 @@ const assertNoApiKey = (headerKey: string) => { } }; +export const jsonOnly = + (impl?: typeof fetch): typeof fetch => + async (input, options) => { + const response = await (impl ?? fetch)(input, options); + + const expectsJson = new Headers(options?.headers) + .get('accept') + ?.includes('json'); + if (!expectsJson || response.status === 204) { + return response; + } + + const contentType = response.headers.get('content-type'); + if (!contentType?.includes('json')) { + throw new MalformedResponseError( + 'Expected a JSON response', + response.url, + response.status, + contentType, + ); + } + + return response; + }; + +export const setFetch = (impl: typeof fetch) => { + defaults.fetch = jsonOnly(impl); +}; + +defaults.fetch = jsonOnly(); + export const getAssetOriginalPath = (id: string) => `/assets/${id}/original`; export const getAssetThumbnailPath = (id: string) => `/assets/${id}/thumbnail`; diff --git a/web/src/lib/utils/server.ts b/web/src/lib/utils/server.ts index e2d0048b20..1e8dc90673 100644 --- a/web/src/lib/utils/server.ts +++ b/web/src/lib/utils/server.ts @@ -1,4 +1,4 @@ -import { defaults } from '@immich/sdk'; +import { setFetch } from '@immich/sdk'; import { memoize } from 'lodash-es'; import { authManager } from '$lib/managers/auth-manager.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; @@ -11,7 +11,7 @@ async function _init(fetch: Fetch) { // set event.fetch on the fetch-client used by @immich/sdk // https://kit.svelte.dev/docs/load#making-fetch-requests // https://github.com/oazapfts/oazapfts/blob/main/README.md#fetch-options - defaults.fetch = fetch; + setFetch(fetch); await initLanguage(); await serverConfigManager.init(); await authManager.load();