Files
accounted/lib/browser/download-file.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

150 lines
5.8 KiB
TypeScript

/**
* Fetch a server-generated file and save it to disk only if the server
* actually produced one.
*
* Why: the download boilerplate that repeats across the app reads
* `await res.blob()` straight after `fetch`, with no `res.ok` check. A failing
* route answers with a JSON error envelope, and `blob()` happily wraps that
* envelope. The anchor click then writes it to disk under the real file's
* name, so the user ends up holding `bokforingsmallar.json` (or an .sie, or a
* .zip) that contains `{"error":{"code":"INTERNAL_ERROR",...}}` while the UI
* reports nothing at all. That file looks like an export, gets filed as a
* backup, gets mailed to an accountant, and only fails much later when someone
* tries to import it.
*
* Content-Type cannot be used to tell the two apart: a JSON export and a JSON
* error envelope are both `application/json`, and a 500 from a route that
* streams a ZIP is still JSON. `res.ok` is the only honest discriminator, so
* the status is checked before the body is ever turned into a blob.
*
* The request is also bounded. An unbounded artefact fetch on a click leaves
* the button spinning forever when a serverless instance hangs, and the user's
* only recourse is to click again.
*
* The result is a discriminated union rather than a thrown error so the call
* site can pick exactly one truthful message. That matters here: TOAST_LIMIT
* is 1 (components/ui/use-toast.tsx), so two toasts emitted in the same tick
* leave the user seeing only the last one.
*/
import { fetchWithTimeout, isTimeoutError } from '@/lib/http/fetch-with-timeout'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
/**
* Default deadline for an interactive artefact download.
*
* These payloads are small (a JSON export, a declaration file) and produced by
* a single indexed query, so the realistic worst case is a serverless cold
* start plus one eu-north-1 round trip: a few seconds. 15s leaves several times
* that headroom, so we never abort a request that was about to succeed, while
* still ending the spinner soon enough after the ~10s mark where the user has
* already concluded the app is dead. Callers that generate genuinely heavy
* artefacts (full-archive ZIPs) should pass a larger `timeoutMs`.
*/
export const DOWNLOAD_TIMEOUT_MS = 15_000
export type DownloadFileResult =
/** 2xx and a complete body: the file was handed to the browser. */
| { ok: true; filename: string }
/** The deadline passed. Nothing was saved; report it as a timeout, not as a generic failure. */
| { ok: false; reason: 'timeout' }
/** The server answered non-2xx. `message` is the envelope's own message when it carries one. */
| { ok: false; reason: 'server'; status: number; message: string }
/** The request never completed (offline, DNS, connection reset, truncated body). */
| { ok: false; reason: 'network'; message: string }
export interface DownloadFileOptions {
url: string
/** Name the file is saved under. */
filename: string
/** UI locale, so a server error is reported in the language the user reads. */
locale?: ErrorLocale
timeoutMs?: number
/** Seam for tests; defaults to the DOM anchor implementation below. */
saveBlob?: (blob: Blob, filename: string) => void
}
/**
* How long the object URL outlives the click before it is revoked.
*
* Revoking synchronously right after `a.click()` can abort the save in
* Firefox and Safari: the click only STARTS the download, and the browser may
* still be reading from the blob URL when it is revoked. 10 seconds is far
* beyond any realistic gap between the click and the browser opening its own
* handle on the blob, while still guaranteeing the URL (and the blob memory
* it pins) is released.
*/
export const OBJECT_URL_REVOKE_DELAY_MS = 10_000
/**
* Hand a blob to the browser as a download.
*
* The anchor is attached to the document before it is clicked: a detached
* anchor is ignored by some browsers, which turns the download into a silent
* no-op.
*/
export function saveBlobToDisk(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
let clicked = false
try {
const a = document.createElement('a')
a.href = url
a.download = filename
a.rel = 'noopener'
document.body.appendChild(a)
a.click()
clicked = true
a.remove()
} finally {
if (clicked) {
// The download started: defer revocation so the browser can finish
// reading the blob (see OBJECT_URL_REVOKE_DELAY_MS).
setTimeout(() => URL.revokeObjectURL(url), OBJECT_URL_REVOKE_DELAY_MS)
} else {
// The click never happened (DOM threw): nothing is reading the URL,
// so release it immediately rather than leaking it for 10 seconds.
URL.revokeObjectURL(url)
}
}
}
export async function downloadFile({
url,
filename,
locale = 'sv',
timeoutMs = DOWNLOAD_TIMEOUT_MS,
saveBlob = saveBlobToDisk,
}: DownloadFileOptions): Promise<DownloadFileResult> {
let blob: Blob
try {
const res = await fetchWithTimeout(
url,
{ method: 'GET' },
{ timeoutMs, description: `download ${filename}` },
)
if (!res.ok) {
// Read the body only now that we know it is not the file. A body that is
// not JSON (an HTML error page, an empty 502) leaves `null`, and
// getErrorMessage falls back to the status map.
const body = await res.json().catch(() => null)
return {
ok: false,
reason: 'server',
status: res.status,
message: getErrorMessage(body, { statusCode: res.status, locale }),
}
}
blob = await res.blob()
} catch (err) {
if (isTimeoutError(err)) return { ok: false, reason: 'timeout' }
return { ok: false, reason: 'network', message: getErrorMessage(err, { locale }) }
}
// Only reached with a 2xx and a body that was read in full.
saveBlob(blob, filename)
return { ok: true, filename }
}