From 9d1c19f5698d30a47390f530a56a1460237232fe Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Tue, 10 Mar 2026 17:58:04 +0100 Subject: [PATCH] fix: dump snapshot --- app/routes/api.$.ts | 19 ++++++++- .../__tests__/repositories.controller.test.ts | 39 +++++++++++++++++++ .../repositories/repositories.controller.ts | 27 +++++++++---- 3 files changed, 77 insertions(+), 8 deletions(-) diff --git a/app/routes/api.$.ts b/app/routes/api.$.ts index 6a88864e..f48ecfc5 100644 --- a/app/routes/api.$.ts +++ b/app/routes/api.$.ts @@ -1,9 +1,26 @@ import { createFileRoute } from "@tanstack/react-router"; import { createApp } from "~/server/app"; +import { config } from "~/server/core/config"; const app = createApp(); -const handle = ({ request }: { request: Request }) => app.fetch(request.clone()); +type NodeRuntimeRequest = Request & { + runtime?: { + node?: { + res?: { setTimeout: (timeoutMs: number) => void }; + }; + }; +}; + +export const prepareApiRequest = (request: Request, timeoutMs: number) => { + const nodeRequest = request as NodeRuntimeRequest; + nodeRequest.runtime?.node?.res?.setTimeout(timeoutMs); + + return request.clone(); +}; + +const handle = ({ request }: { request: Request }) => + app.fetch(prepareApiRequest(request, config.serverIdleTimeout * 1000)); export const Route = createFileRoute("/api/$")({ server: { diff --git a/app/server/modules/repositories/__tests__/repositories.controller.test.ts b/app/server/modules/repositories/__tests__/repositories.controller.test.ts index ebc72a9a..006c3569 100644 --- a/app/server/modules/repositories/__tests__/repositories.controller.test.ts +++ b/app/server/modules/repositories/__tests__/repositories.controller.test.ts @@ -1,5 +1,6 @@ import { test, describe, expect, spyOn } from "bun:test"; import crypto from "node:crypto"; +import { PassThrough } from "node:stream"; import { createApp } from "~/server/app"; import { db } from "~/server/db/db"; import { repositoriesTable } from "~/server/db/schema"; @@ -299,4 +300,42 @@ describe("repositories updates", () => { } }); }); + + describe("dump snapshot", () => { + test("continues streaming a download after the request signal aborts", async () => { + const { headers, organizationId } = await createTestSession(); + const repository = await createRepositoryRecord(organizationId); + const { repositoriesService } = await import("~/server/modules/repositories/repositories.service"); + + const stream = new PassThrough(); + const expectedContent = "downloaded snapshot contents"; + + const dumpSnapshotSpy = spyOn(repositoriesService, "dumpSnapshot").mockResolvedValue({ + stream, + completion: Promise.resolve(), + abort: () => { + stream.destroy(new Error("download aborted")); + }, + filename: "snapshot.txt", + contentType: "application/octet-stream", + }); + + try { + const controller = new AbortController(); + const response = await app.request(`/api/v1/repositories/${repository.shortId}/snapshots/test-snapshot/dump`, { + headers, + signal: controller.signal, + }); + + queueMicrotask(() => { + controller.abort(); + stream.end(expectedContent); + }); + + await expect(response.text()).resolves.toBe(expectedContent); + } finally { + dumpSnapshotSpy.mockRestore(); + } + }); + }); }); diff --git a/app/server/modules/repositories/repositories.controller.ts b/app/server/modules/repositories/repositories.controller.ts index 5e0515f7..0da2630b 100644 --- a/app/server/modules/repositories/repositories.controller.ts +++ b/app/server/modules/repositories/repositories.controller.ts @@ -195,15 +195,28 @@ export const repositoriesController = new Hono() const { path, kind } = c.req.valid("query"); const dumpStream = await repositoriesService.dumpSnapshot(shortId, snapshotId, path, kind); - const signal = c.req.raw.signal; + const sourceStream = Readable.toWeb(dumpStream.stream) as unknown as ReadableStream; + const reader = sourceStream.getReader(); + const webStream = new ReadableStream({ + async pull(controller) { + try { + const { done, value } = await reader.read(); - if (signal.aborted) { - dumpStream.abort(); - } else { - signal.addEventListener("abort", () => dumpStream.abort(), { once: true }); - } + if (done) { + controller.close(); + return; + } - const webStream = Readable.toWeb(dumpStream.stream) as unknown as ReadableStream; + controller.enqueue(value); + } catch (error) { + controller.error(error); + } + }, + async cancel(reason) { + dumpStream.abort(); + await reader.cancel(reason).catch(() => {}); + }, + }); return new Response(webStream, { status: 200,