Skip to content

Commit be83336

Browse files
Goosterhofclaude
andcommitted
feat(fs-http)!: remove DOM coupling from download/preview (0.3.0)
Closes #59. fs-http should be HTTP transport, not browser download UX. The previous downloadRequest/previewRequest methods constructed Blobs, created `<a>` elements, dispatched clicks, and managed object URL lifecycles — coupling that bled into every consumer's tests as fragile global stubs (vi.stubGlobal of `Blob`/`document`/`window.URL`), exposed to vitest 4's class-mock requirement and oxfmt's arrow-function collapse. Three territories independently rediscovered the mitigation. Reshape both methods to pure transport: `(endpoint, options?) → Promise<AxiosResponse<Blob>>`. The DOM-side download dance moves to `@script-development/fs-helpers` ≥ 0.1.2 as `triggerDownload(blob, filename)`. Object-URL lifecycle for previews is now the consumer's concern (one `URL.createObjectURL` line, plus revocation on cleanup). BREAKING CHANGES (fs-http 0.2.0 → 0.3.0): - downloadRequest(endpoint, documentName, type?) → AxiosResponse becomes downloadRequest(endpoint, options?) → AxiosResponse<Blob>. - previewRequest(endpoint) → string (object URL) becomes previewRequest(endpoint, options?) → AxiosResponse<Blob>. - Removed HEADERS_TO_TYPE map (one-entry OOXML lookup, did not earn its place in transport-layer code; consumers can supply their own if needed). Cascade workspace bumps to keep the lock coherent: - fs-helpers 0.1.1 → 0.1.2 (additive: triggerDownload export + happy-dom devDep). - fs-adapter-store 0.1.5 → 0.1.6 (peer range widened to include ^0.3.0 — not a behavior change; doesn't depend on download/preview). - fs-loading 0.1.1 → 0.1.2 (same widening; same rationale). Out of scope (separate dispositions): - streamRequest's `document.cookie` XSRF read remains. It's the only remaining DOM touch in fs-http; cleanup would require either a required `xsrfHeader` option or a cookie-reader middleware shipped alongside, both bigger migrations than #59 covers. - Consumer territory upgrades. Land this PR after #60, then run a post-publish migration campaign across kendo, ublgenie, emmie, entreezuil, and BIO (5 download/preview call sites in production code; ~50 in test mocks). Verified locally: - 19 test files / 430 tests pass (was 18/430; +2 fs-helpers, -2 net fs-http after dropping obsolete DOM-orchestration tests). - 100% coverage maintained on fs-http and fs-helpers. - mutation: fs-http 97.30% (up from 95.74%; less surface to mutate), fs-helpers 100% (including the new dom-download.ts). - All 8 CI gates locally green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8c457ee commit be83336

12 files changed

Lines changed: 200 additions & 184 deletions

File tree

package-lock.json

Lines changed: 11 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/adapter-store/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@script-development/fs-adapter-store",
3-
"version": "0.1.5",
3+
"version": "0.1.6",
44
"description": "Reactive adapter-store pattern with domain state management and CRUD resource adapters",
55
"homepage": "https://packages.script.nl/packages/adapter-store",
66
"license": "MIT",
@@ -42,15 +42,15 @@
4242
},
4343
"devDependencies": {
4444
"@script-development/fs-helpers": "^0.1.0",
45-
"@script-development/fs-http": "^0.1.0 || ^0.2.0",
45+
"@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0",
4646
"@script-development/fs-loading": "^0.1.0",
4747
"@script-development/fs-storage": "^0.1.0",
4848
"happy-dom": "^20.9.0",
4949
"vue": "^3.5.33"
5050
},
5151
"peerDependencies": {
5252
"@script-development/fs-helpers": "^0.1.0",
53-
"@script-development/fs-http": "^0.1.0 || ^0.2.0",
53+
"@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0",
5454
"@script-development/fs-loading": "^0.1.0",
5555
"@script-development/fs-storage": "^0.1.0",
5656
"vue": "^3.5.33"

packages/helpers/package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@script-development/fs-helpers",
3-
"version": "0.1.1",
4-
"description": "Tree-shakeable shared utility helpers: deep copy, type guards, and case conversion",
3+
"version": "0.1.2",
4+
"description": "Tree-shakeable shared utility helpers: deep copy, type guards, case conversion, and browser download",
55
"homepage": "https://packages.script.nl/packages/helpers",
66
"license": "MIT",
77
"repository": {
@@ -43,6 +43,9 @@
4343
"dependencies": {
4444
"string-ts": "^2.3.1"
4545
},
46+
"devDependencies": {
47+
"happy-dom": "^20.9.0"
48+
},
4649
"engines": {
4750
"node": ">=24.0.0"
4851
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* Triggers a browser download for a Blob.
3+
*
4+
* Creates a transient `<a>` element pointing at an object URL, dispatches a
5+
* click, then revokes the URL. The browser's download UI captures its own
6+
* reference during click(), so revoking immediately does not interrupt the
7+
* in-flight download — it simply releases the blob reference held by the URL.
8+
*
9+
* Lives in `fs-helpers` rather than `fs-http` so the HTTP factory remains a
10+
* transport-only library with no DOM coupling (fs-packages issue #59).
11+
*/
12+
export const triggerDownload = (blob: Blob, filename: string): void => {
13+
const link = document.createElement('a');
14+
link.href = URL.createObjectURL(blob);
15+
link.download = filename;
16+
link.click();
17+
URL.revokeObjectURL(link.href);
18+
};

packages/helpers/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@ export type {Writable} from './deep-copy';
44
export {isExisting} from './type-guards';
55

66
export {toCamelCaseTyped, deepCamelKeys, deepSnakeKeys} from './case-conversion';
7+
8+
export {triggerDownload} from './dom-download';
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// @vitest-environment happy-dom
2+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
3+
4+
import {triggerDownload} from '../src';
5+
6+
describe('triggerDownload', () => {
7+
let createObjectURL: ReturnType<typeof vi.fn>;
8+
let revokeObjectURL: ReturnType<typeof vi.fn>;
9+
10+
beforeEach(() => {
11+
createObjectURL = vi.fn(() => 'blob:https://test/fake-object-url');
12+
revokeObjectURL = vi.fn();
13+
vi.stubGlobal('URL', {createObjectURL, revokeObjectURL});
14+
});
15+
16+
afterEach(() => {
17+
vi.unstubAllGlobals();
18+
vi.restoreAllMocks();
19+
});
20+
21+
it('creates an anchor with the object URL and download filename, clicks, then revokes', () => {
22+
// Arrange
23+
const blob = new Blob(['file-content'], {type: 'application/pdf'});
24+
const link = document.createElement('a');
25+
const clickSpy = vi.spyOn(link, 'click').mockImplementation(() => {});
26+
const createElementSpy = vi.spyOn(document, 'createElement').mockReturnValue(link);
27+
28+
// Act
29+
triggerDownload(blob, 'report.pdf');
30+
31+
// Assert — anchor created with the right shape and clicked
32+
expect(createElementSpy).toHaveBeenCalledWith('a');
33+
expect(createObjectURL).toHaveBeenCalledWith(blob);
34+
expect(link.href).toBe('blob:https://test/fake-object-url');
35+
expect(link.download).toBe('report.pdf');
36+
expect(clickSpy).toHaveBeenCalledTimes(1);
37+
38+
// Assert — revoked with the same URL that was assigned to href
39+
expect(revokeObjectURL).toHaveBeenCalledTimes(1);
40+
expect(revokeObjectURL).toHaveBeenCalledWith('blob:https://test/fake-object-url');
41+
});
42+
43+
it('revokes after click so the browser can release the blob reference', () => {
44+
// Arrange — record the order of click vs revoke
45+
const callOrder: string[] = [];
46+
const blob = new Blob(['data']);
47+
const link = document.createElement('a');
48+
vi.spyOn(link, 'click').mockImplementation(() => {
49+
callOrder.push('click');
50+
});
51+
vi.spyOn(document, 'createElement').mockReturnValue(link);
52+
revokeObjectURL.mockImplementation(() => {
53+
callOrder.push('revoke');
54+
});
55+
56+
// Act
57+
triggerDownload(blob, 'file.bin');
58+
59+
// Assert — click fires before revoke (the in-flight download captures its own ref)
60+
expect(callOrder).toEqual(['click', 'revoke']);
61+
});
62+
});

packages/http/CHANGELOG.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,39 @@
11
# @script-development/fs-http
22

3+
## 0.3.0 — 2026-04-30
4+
5+
### Breaking Changes
6+
7+
- **`downloadRequest` no longer touches the DOM.** Signature changes from `(endpoint, documentName, type?) → Promise<AxiosResponse>` to `(endpoint, options?) → Promise<AxiosResponse<Blob>>`. The browser download dance (`Blob` construction, `<a>` element, `link.click`, object URL lifecycle) moves to consumer code. Use `triggerDownload(blob, filename)` from `@script-development/fs-helpers` ≥ 0.1.2 to reproduce the prior behavior in one call.
8+
- **`previewRequest` no longer touches the DOM.** Signature changes from `(endpoint) → Promise<string>` (object URL) to `(endpoint, options?) → Promise<AxiosResponse<Blob>>` (response with the raw Blob). Consumers manage object-URL lifecycle: `URL.createObjectURL(response.data)` to render and `URL.revokeObjectURL(...)` on cleanup.
9+
- **Removed:** `HEADERS_TO_TYPE` map (was used internally to resolve OOXML to xlsx). Consumers that need MIME mapping can supply their own table; the prior table was a one-entry lookup that did not earn its place in transport-layer code.
10+
11+
### Why
12+
13+
`fs-http` should be HTTP transport. Coupling to `Blob`, `document.createElement`, and `URL.createObjectURL`/`revokeObjectURL` made every consumer's tests responsible for stubbing browser globals — fragile under formatters (oxfmt collapsed `function () { return obj }` into arrow-functions, breaking constructor-mock patterns across kendo and ublgenie in April 2026), exposed to vitest 4's class-mock requirement, and a coupling smell between library and test environment. Closes [#59](https://github.com/script-development/fs-packages/issues/59).
14+
15+
### Migration
16+
17+
```ts
18+
// before (0.2.x)
19+
await http.downloadRequest('/files/123/download', 'report.pdf');
20+
21+
// after (0.3.x)
22+
import {triggerDownload} from '@script-development/fs-helpers';
23+
const {data} = await http.downloadRequest('/files/123/download');
24+
triggerDownload(data, 'report.pdf');
25+
```
26+
27+
```ts
28+
// before (0.2.x)
29+
const blobUrl = await http.previewRequest('/files/123/preview');
30+
31+
// after (0.3.x)
32+
const {data} = await http.previewRequest('/files/123/preview');
33+
const blobUrl = URL.createObjectURL(data);
34+
// remember to revoke on cleanup: URL.revokeObjectURL(blobUrl)
35+
```
36+
337
## 0.2.0 — 2026-04-30
438

539
### Minor Changes

packages/http/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@script-development/fs-http",
3-
"version": "0.2.0",
3+
"version": "0.3.0",
44
"description": "Framework-agnostic HTTP service factory with middleware architecture",
55
"homepage": "https://packages.script.nl/packages/http",
66
"license": "MIT",

packages/http/src/http.ts

Lines changed: 9 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,6 @@ import type {
1414

1515
import {isAxiosError} from './utils';
1616

17-
const HEADERS_TO_TYPE: Record<string, string> = {
18-
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'application/xlsx',
19-
};
20-
2117
/**
2218
* Default request timeout in milliseconds (30s). Applied when
2319
* `HttpServiceOptions.timeout` is unset. Per Doctrine #8 (library-author
@@ -27,12 +23,6 @@ const HEADERS_TO_TYPE: Record<string, string> = {
2723
*/
2824
export const DEFAULT_TIMEOUT_MS = 30_000;
2925

30-
// axios 1.15+ types `response.headers[key]` as `AxiosHeaderValue | undefined`
31-
// (string | string[] | number | boolean | null | AxiosHeaders | undefined). For
32-
// content-type handling we only honor a real string; any other shape falls back
33-
// to undefined so the consumer's default applies.
34-
const asString = (value: unknown): string | undefined => (typeof value === 'string' ? value : undefined);
35-
3626
const unregister = <T>(array: T[], item: T): UnregisterMiddleware => {
3727
return () => {
3828
const index = array.indexOf(item);
@@ -103,40 +93,17 @@ export const createHttpService = (baseURL: string, options?: HttpServiceOptions)
10393
const deleteRequest = <T = unknown>(endpoint: string, options?: AxiosRequestConfig) =>
10494
http.delete<T>(endpoint, options);
10595

106-
// Browser-dependent methods
107-
108-
const getContentType = (headerContentType?: string, type?: string): string => {
109-
if (type) return type;
110-
if (headerContentType) return HEADERS_TO_TYPE[headerContentType] || headerContentType;
111-
throw new Error('No content type found');
112-
};
113-
114-
const downloadRequest = async (endpoint: string, documentName: string, type?: string) => {
115-
const response = await http.get(endpoint, {responseType: 'blob'});
116-
const {data, headers} = response;
96+
// Blob-returning request methods. Identical transport (responseType: 'blob');
97+
// separate names communicate intent to consumers (download = save-to-disk,
98+
// preview = inline-display). Neither touches the DOM — orchestration of the
99+
// download dance and object-URL lifecycle lives with the consumer (see
100+
// `triggerDownload` in `@script-development/fs-helpers`).
117101

118-
const actualType = getContentType(asString(headers['content-type']), type);
102+
const downloadRequest = (endpoint: string, options?: AxiosRequestConfig) =>
103+
http.get<Blob>(endpoint, {...options, responseType: 'blob'});
119104

120-
const blob = new Blob([data], {type: actualType});
121-
const link = document.createElement('a');
122-
link.href = window.URL.createObjectURL(blob);
123-
link.download = documentName;
124-
link.click();
125-
// Revoke the object URL after the click so the browser can release the
126-
// blob reference. The download itself captures its own reference during
127-
// link.click(), so revoking here does not interrupt it.
128-
window.URL.revokeObjectURL(link.href);
129-
130-
return response;
131-
};
132-
133-
const previewRequest = async (endpoint: string): Promise<string> => {
134-
const response = await http.get(endpoint, {responseType: 'blob'});
135-
const contentType = asString(response.headers['content-type']) ?? 'application/octet-stream';
136-
const blob = new Blob([response.data], {type: contentType});
137-
138-
return window.URL.createObjectURL(blob);
139-
};
105+
const previewRequest = (endpoint: string, options?: AxiosRequestConfig) =>
106+
http.get<Blob>(endpoint, {...options, responseType: 'blob'});
140107

141108
const streamRequest = (endpoint: string, data: unknown, signal?: AbortSignal): Promise<Response> => {
142109
const headers: Record<string, string> = {'content-type': 'application/json', accept: 'application/json'};

packages/http/src/types.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,22 @@ export type HttpService = {
4040
options?: AxiosRequestConfig,
4141
) => Promise<AxiosResponse<T>>;
4242
deleteRequest: <T = unknown>(endpoint: string, options?: AxiosRequestConfig) => Promise<AxiosResponse<T>>;
43-
downloadRequest: (endpoint: string, documentName: string, type?: string) => Promise<AxiosResponse>;
44-
previewRequest: (endpoint: string) => Promise<string>;
43+
/**
44+
* GET an endpoint as a Blob, intended for save-to-disk flows. Returns the
45+
* full AxiosResponse so callers can read headers (e.g. content-type) before
46+
* handing off to a download utility such as `fs-helpers`' `triggerDownload`.
47+
*
48+
* No DOM side effects — fs-http is transport-only (fs-packages issue #59).
49+
*/
50+
downloadRequest: (endpoint: string, options?: AxiosRequestConfig) => Promise<AxiosResponse<Blob>>;
51+
/**
52+
* GET an endpoint as a Blob, intended for inline-display flows. Identical
53+
* transport to `downloadRequest`; the separate name communicates intent.
54+
*
55+
* Callers manage object-URL lifecycle: `URL.createObjectURL(response.data)`
56+
* to render and `URL.revokeObjectURL(...)` on cleanup.
57+
*/
58+
previewRequest: (endpoint: string, options?: AxiosRequestConfig) => Promise<AxiosResponse<Blob>>;
4559
streamRequest: (endpoint: string, data: unknown, signal?: AbortSignal) => Promise<Response>;
4660
registerRequestMiddleware: (fn: RequestMiddlewareFunc) => UnregisterMiddleware;
4761
registerResponseMiddleware: (fn: ResponseMiddlewareFunc) => UnregisterMiddleware;

0 commit comments

Comments
 (0)