Skip to content

Commit f6fcfaf

Browse files
riglarclaude
andauthored
feat(artifacts): prefer server-assembled bundle delivery for downloads (#93)
* feat(artifacts): prefer server-assembled bundle delivery for downloads Artifact and HTML-report downloads now try a bundle-delivery path first: the API returns a signed manifest plus a URL to a delivery service that streams the ZIP straight from storage, so large downloads don't flow through the API. The client relays the signed { manifest, sig } to that URL (no auth header — the manifest is the signed token) and streams the result to disk. Falls back to the existing inline download automatically when bundle delivery isn't offered (501) or anything about the path doesn't pan out, so behaviour is unchanged on older deployments. Applies to artifacts and the HTML report; junit and allure keep using their existing endpoints. Adds a shared tryBundleDownload helper in the API gateway and unit tests covering the bundle path, the no-auth relay, the 501 fallback, and the HTML report. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(artifacts): fall back to inline download on bundle stream failure streamResponseToFile threw past both tryBundleDownload call sites on a mid-stream failure or null body, escaping the inline fallback and leaving a partial file. Wrap it to return false like every other failure path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7d85979 commit f6fcfaf

2 files changed

Lines changed: 260 additions & 0 deletions

File tree

src/gateways/api-gateway.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,84 @@ export const ApiGateway = {
179179
);
180180
},
181181

182+
/**
183+
* Prefer server-assembled bundle delivery. The API returns a signed manifest
184+
* plus a URL to a delivery service that streams the ZIP straight from
185+
* storage, so large downloads don't flow through the API itself. The client
186+
* just relays the signed `{ manifest, sig }` to that URL — no auth header,
187+
* because the manifest is already signed.
188+
*
189+
* Returns true once the bundle has been streamed to disk. Returns false when
190+
* the bundle path is unavailable or anything about it doesn't pan out — a
191+
* `501` (deployment doesn't offer it), a non-OK manifest response, a body
192+
* that isn't a manifest, or a delivery-service error — so the caller can fall
193+
* back to the inline download endpoint. Definitive errors (e.g. not found)
194+
* surface through that inline path instead.
195+
*/
196+
async tryBundleDownload(
197+
baseUrl: string,
198+
auth: AuthContext,
199+
manifestEndpoint: string,
200+
destinationPath: string,
201+
operation: string,
202+
): Promise<boolean> {
203+
let manifestRes: Response;
204+
try {
205+
manifestRes = await fetch(`${baseUrl}${manifestEndpoint}`, {
206+
headers: { ...auth.headers },
207+
method: 'GET',
208+
});
209+
} catch {
210+
return false;
211+
}
212+
213+
// 501 => this deployment has no bundle delivery; any other non-OK => let
214+
// the inline path re-request and surface the real error.
215+
if (!manifestRes.ok) {
216+
return false;
217+
}
218+
219+
let bundle: {
220+
bundleUrl?: string;
221+
manifest?: string;
222+
sig?: string;
223+
};
224+
try {
225+
bundle = (await manifestRes.json()) as typeof bundle;
226+
} catch {
227+
return false;
228+
}
229+
if (!bundle?.bundleUrl || !bundle?.manifest || !bundle?.sig) {
230+
return false;
231+
}
232+
233+
let zipRes: Response;
234+
try {
235+
zipRes = await fetch(bundle.bundleUrl, {
236+
body: JSON.stringify({ manifest: bundle.manifest, sig: bundle.sig }),
237+
// No auth header: the manifest is signed and is the access token.
238+
headers: { 'content-type': 'application/json' },
239+
method: 'POST',
240+
});
241+
} catch {
242+
return false;
243+
}
244+
if (!zipRes.ok) {
245+
return false;
246+
}
247+
248+
// A mid-stream failure (dropped/truncated connection) or a null body on an
249+
// otherwise-OK response throws here — fall back to the inline path rather
250+
// than let it escape past the caller's fallback. The inline path re-opens
251+
// the destination with flags: 'w', truncating any partial file left behind.
252+
try {
253+
await this.streamResponseToFile(zipRes, destinationPath, operation);
254+
} catch {
255+
return false;
256+
}
257+
return true;
258+
},
259+
182260
async checkForExistingUpload(
183261
baseUrl: string,
184262
auth: AuthContext,
@@ -219,6 +297,19 @@ export const ApiGateway = {
219297
results: 'ALL' | 'FAILED',
220298
artifactsPath: string = './artifacts.zip',
221299
) {
300+
// Prefer bundle delivery; fall back to the inline download below.
301+
if (
302+
await this.tryBundleDownload(
303+
baseUrl,
304+
auth,
305+
`/results/${uploadId}/artifacts-bundle?results=${results}`,
306+
artifactsPath,
307+
'Failed to download artifacts',
308+
)
309+
) {
310+
return;
311+
}
312+
222313
try {
223314
const res = await fetch(`${baseUrl}/results/${uploadId}/download`, {
224315
body: JSON.stringify({ results }),
@@ -677,6 +768,22 @@ export const ApiGateway = {
677768
const finalReportPath = reportPath || path.resolve(process.cwd(), defaultFilename);
678769
const url = `${baseUrl}${endpoint}`;
679770

771+
// The HTML report is a ZIP bundle; prefer bundle delivery when available.
772+
// (junit is a single small file and allure has its own endpoint — both stay
773+
// on the inline path.)
774+
if (
775+
reportType === 'html' &&
776+
(await this.tryBundleDownload(
777+
baseUrl,
778+
auth,
779+
`/results/${uploadId}/report-bundle`,
780+
finalReportPath,
781+
errorPrefix,
782+
))
783+
) {
784+
return;
785+
}
786+
680787
try {
681788
// Make the download request
682789
const res = await fetch(url, {

test/unit/report-download.service.test.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,4 +303,157 @@ describe('ReportDownloadService', () => {
303303
expect(warnings.join(' ')).to.match(/failed to download allure/i);
304304
});
305305
});
306+
307+
// -------------------------------------------------------------------------
308+
// bundle delivery
309+
// -------------------------------------------------------------------------
310+
311+
describe('bundle delivery', () => {
312+
const BASE = {
313+
auth: TEST_AUTH,
314+
apiUrl: 'https://api.example.com',
315+
uploadId: 'run-42',
316+
};
317+
318+
let calls: Array<{
319+
headers: Record<string, string>;
320+
method: string;
321+
url: string;
322+
}>;
323+
324+
/**
325+
* Route fetch by URL: a `*-bundle` endpoint returns a signed manifest, and
326+
* the manifest's `bundleUrl` streams the ZIP. Records every call so the
327+
* flow (manifest GET, then bundle POST, no inline call) can be asserted.
328+
*/
329+
function mockBundleFetch(
330+
opts: { manifestStatus?: number; zipBody?: string } = {},
331+
) {
332+
const { manifestStatus = 200, zipBody = 'bundle-zip' } = opts;
333+
const encoder = new TextEncoder();
334+
const stream = (s: string) =>
335+
new ReadableStream({
336+
start(controller) {
337+
controller.enqueue(encoder.encode(s));
338+
controller.close();
339+
},
340+
});
341+
342+
const impl = async (
343+
input: URL | string,
344+
init?: RequestInit,
345+
): Promise<Response> => {
346+
const url = input.toString();
347+
calls.push({
348+
headers: Object.fromEntries(
349+
Object.entries((init?.headers as Record<string, string>) ?? {}),
350+
),
351+
method: (init?.method ?? 'GET').toUpperCase(),
352+
url,
353+
});
354+
355+
if (url.includes('artifacts-bundle') || url.includes('report-bundle')) {
356+
return new Response(
357+
stream(
358+
JSON.stringify({
359+
bundleUrl: 'https://cdn.example.com/bundle',
360+
entryCount: 3,
361+
filename: 'artifacts-all.zip',
362+
manifest: '{"version":1}',
363+
sig: 'SIG',
364+
}),
365+
),
366+
{
367+
headers: { 'content-type': 'application/json' },
368+
status: manifestStatus,
369+
},
370+
);
371+
}
372+
if (url === 'https://cdn.example.com/bundle') {
373+
return new Response(stream(zipBody), { status: 200 });
374+
}
375+
return new Response(stream('inline-zip'), { status: 200 });
376+
};
377+
globalThis.fetch = impl as typeof fetch;
378+
}
379+
380+
beforeEach(() => {
381+
calls = [];
382+
});
383+
384+
it('streams from the bundle URL and skips the inline endpoint', async () => {
385+
mockBundleFetch();
386+
const outPath = path.join(tempDir, 'bundle.zip');
387+
388+
await service.downloadArtifacts({
389+
...BASE,
390+
artifactsPath: outPath,
391+
downloadType: 'ALL',
392+
});
393+
394+
expect(calls[0]).to.include({
395+
method: 'GET',
396+
url: 'https://api.example.com/results/run-42/artifacts-bundle?results=ALL',
397+
});
398+
expect(calls[1]).to.include({
399+
method: 'POST',
400+
url: 'https://cdn.example.com/bundle',
401+
});
402+
expect(calls.some((c) => c.url.endsWith('/download'))).to.be.false;
403+
expect(fs.readFileSync(outPath, 'utf8')).to.equal('bundle-zip');
404+
});
405+
406+
it('does not send the auth header to the (pre-signed) bundle URL', async () => {
407+
mockBundleFetch();
408+
409+
await service.downloadArtifacts({
410+
...BASE,
411+
artifactsPath: path.join(tempDir, 'bundle-noauth.zip'),
412+
downloadType: 'ALL',
413+
});
414+
415+
const bundlePost = calls.find(
416+
(c) => c.url === 'https://cdn.example.com/bundle',
417+
);
418+
expect(bundlePost).to.not.be.undefined;
419+
expect(bundlePost!.headers['x-app-api-key']).to.be.undefined;
420+
});
421+
422+
it('falls back to the inline download when unavailable (501)', async () => {
423+
mockBundleFetch({ manifestStatus: 501 });
424+
const outPath = path.join(tempDir, 'bundle-fallback.zip');
425+
426+
await service.downloadArtifacts({
427+
...BASE,
428+
artifactsPath: outPath,
429+
downloadType: 'ALL',
430+
});
431+
432+
expect(
433+
calls.some((c) => c.url.endsWith('/artifacts-bundle?results=ALL')),
434+
).to.be.true;
435+
expect(
436+
calls.some((c) => c.method === 'POST' && c.url.endsWith('/download')),
437+
).to.be.true;
438+
expect(fs.readFileSync(outPath, 'utf8')).to.equal('inline-zip');
439+
});
440+
441+
it('uses bundle delivery for the html report', async () => {
442+
mockBundleFetch();
443+
444+
await service.downloadReports({
445+
...BASE,
446+
htmlPath: path.join(tempDir, 'report-bundle.zip'),
447+
reportType: 'html',
448+
});
449+
450+
expect(calls[0].url).to.equal(
451+
'https://api.example.com/results/run-42/report-bundle',
452+
);
453+
expect(calls[1]).to.include({
454+
method: 'POST',
455+
url: 'https://cdn.example.com/bundle',
456+
});
457+
});
458+
});
306459
});

0 commit comments

Comments
 (0)