diff --git a/web-app/package-lock.json b/web-app/package-lock.json index e1eab02f8..842c241a5 100644 --- a/web-app/package-lock.json +++ b/web-app/package-lock.json @@ -13,8 +13,7 @@ "clsx": "^2.1.1", "native-file-system-adapter": "^3.0.1", "react": "^19.0.0", - "react-dom": "^19.0.0", - "streamsaver": "^2.0.6" + "react-dom": "^19.0.0" }, "devDependencies": { "@eslint/eslintrc": "^3.3.0", @@ -23,7 +22,6 @@ "@rollup/plugin-inject": "^5.0.5", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", - "@types/streamsaver": "^2.0.5", "@types/wicg-file-system-access": "^2023.10.5", "@vitejs/plugin-react": "^4.3.4", "@vitest/ui": "^3.0.7", @@ -56,15 +54,15 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" @@ -188,9 +186,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "dev": true, "license": "MIT", "engines": { @@ -198,9 +196,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", "dev": true, "license": "MIT", "engines": { @@ -218,27 +216,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.9.tgz", - "integrity": "sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA==", + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", + "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.26.9", - "@babel/types": "^7.26.9" + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz", - "integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==", + "version": "7.27.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.5.tgz", + "integrity": "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.26.9" + "@babel/types": "^7.27.3" }, "bin": { "parser": "bin/babel-parser.js" @@ -280,15 +278,15 @@ } }, "node_modules/@babel/template": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", - "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/parser": "^7.26.9", - "@babel/types": "^7.26.9" + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -324,14 +322,14 @@ } }, "node_modules/@babel/types": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz", - "integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==", + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.6.tgz", + "integrity": "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1607,13 +1605,6 @@ "@types/react": "^19.0.0" } }, - "node_modules/@types/streamsaver": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/streamsaver/-/streamsaver-2.0.5.tgz", - "integrity": "sha512-93o0zjV8swEhR2YI57h/2ytbJF8bJh7sI9GNB02TLJHdM4fWDxZuChwfWhyD8vt2ub4kw4rsfZ0C0yAUX+3gcg==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/wicg-file-system-access": { "version": "2023.10.5", "resolved": "https://registry.npmjs.org/@types/wicg-file-system-access/-/wicg-file-system-access-2023.10.5.tgz", @@ -1760,9 +1751,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2130,9 +2121,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -3944,9 +3935,9 @@ } }, "node_modules/read-installed-packages/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4327,18 +4318,6 @@ "dev": true, "license": "MIT" }, - "node_modules/streamsaver": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/streamsaver/-/streamsaver-2.0.6.tgz", - "integrity": "sha512-LK4e7TfCV8HzuM0PKXuVUfKyCB1FtT9L0EGxsFk5Up8njj0bXK8pJM9+Wq2Nya7/jslmCQwRK39LFm55h7NBTw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - } - ], - "license": "MIT" - }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", diff --git a/web-app/package.json b/web-app/package.json index 95bcf5cb0..dd3c70584 100644 --- a/web-app/package.json +++ b/web-app/package.json @@ -21,8 +21,7 @@ "clsx": "^2.1.1", "native-file-system-adapter": "^3.0.1", "react": "^19.0.0", - "react-dom": "^19.0.0", - "streamsaver": "^2.0.6" + "react-dom": "^19.0.0" }, "devDependencies": { "@eslint/eslintrc": "^3.3.0", @@ -31,7 +30,6 @@ "@rollup/plugin-inject": "^5.0.5", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", - "@types/streamsaver": "^2.0.5", "@types/wicg-file-system-access": "^2023.10.5", "@vitejs/plugin-react": "^4.3.4", "@vitest/ui": "^3.0.7", diff --git a/web-app/src/App.tsx b/web-app/src/App.tsx index 9d1c9fa6b..297dcb3ee 100644 --- a/web-app/src/App.tsx +++ b/web-app/src/App.tsx @@ -1,24 +1,11 @@ import { clsx } from 'clsx'; import { useState, useEffect, type ChangeEvent } from 'react'; -import streamsaver from 'streamsaver'; import { showSaveFilePicker } from 'native-file-system-adapter'; import './App.css'; import { type Chunker, type Source, OpenTDF } from '@opentdf/sdk'; import { type SessionInformation, OidcClient } from './session.js'; import { c } from './config.js'; - -async function toFile( - stream: ReadableStream, - filepath = 'download.tdf', - options?: StreamPipeOptions -): Promise { - const fileStream = streamsaver.createWriteStream(filepath, { - writableStrategy: { highWaterMark: 1 }, - readableStrategy: { highWaterMark: 1 }, - }); - - return stream.pipeTo(fileStream, options); -} +import { downloadReadableStream } from './utils/download-readable-stream'; function decryptedFileName(encryptedFileName: string): string { // Groups: 1 file 'name' bit @@ -393,7 +380,7 @@ function App() { try { switch (sinkType) { case 'file': - await toFile(cipherTextWithProgress, downloadName, { signal: sc.signal }); + await downloadReadableStream(cipherTextWithProgress, downloadName, { signal: sc.signal }); break; case 'fsapi': if (!f) { @@ -468,7 +455,7 @@ function App() { .pipeThrough(progressTransformers.writer); switch (sinkType) { case 'file': - await toFile(plainTextStream, dfn, { signal: sc.signal }); + await downloadReadableStream(plainTextStream, dfn, { signal: sc.signal }); break; case 'fsapi': if (!f) { diff --git a/web-app/src/utils/download-readable-stream.ts b/web-app/src/utils/download-readable-stream.ts new file mode 100644 index 000000000..372657631 --- /dev/null +++ b/web-app/src/utils/download-readable-stream.ts @@ -0,0 +1,115 @@ +/** + * Downloads a ReadableStream as a file using a service worker stream (streamsaver-like) or a Blob fallback. + * Efficient for large files and supports aborting via AbortSignal in options. + */ +export async function downloadReadableStream( + stream: ReadableStream, + filename = 'download.tdf', + options?: { signal?: AbortSignal } +): Promise { + if (typeof navigator !== 'undefined' && typeof navigator?.serviceWorker !== 'undefined') { + const swUrl = '/sw.js'; + try { + await navigator.serviceWorker.register(swUrl, { scope: '/' }); + await navigator.serviceWorker.ready; + } catch (e) { + console.log('Downloading service worker registration failed:', e); + return fallbackDownload(stream, filename, options); + } + + const channel = new MessageChannel(); + const downloadId = Math.random().toString(36).slice(2); + navigator.serviceWorker.controller?.postMessage( + { + downloadId, + filename, + port: channel.port2, + }, + [channel.port2] + ); + + // Pipe the stream to the service worker via the channel + if (typeof stream.pipeTo === 'function') { + const writer = channel.port1; + const reader = stream.getReader(); + function readAndSend() { + reader.read().then(({ done, value }) => { + if (done) { + writer.postMessage({ done: true }); + writer.close(); + return; + } + writer.postMessage({ chunk: value }); + readAndSend(); + }); + } + readAndSend(); + } + + // Hidden iframe triggers the browser download event + const iframe = document.createElement('iframe'); + iframe.style.display = 'none'; + iframe.src = `/stream-saver-download?downloadId=${downloadId}`; + document.body.appendChild(iframe); + setTimeout(() => { + document.body.removeChild(iframe); + }, 2000); // Clean up after 2 seconds + } else { + fallbackDownload(stream, filename, options); + } +} + +// Fallback: collect stream into a Blob and trigger download via anchor +async function fallbackDownload( + stream: ReadableStream, + filename: string, + options?: { signal?: AbortSignal } +) { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let done = false; + let aborted = false; + const signal = options?.signal; + let abortHandler: (() => void) | undefined; + + if (signal) { + if (signal.aborted) { + throw new DOMException('Aborted', 'AbortError'); + } + abortHandler = () => { + aborted = true; + reader.cancel(); + }; + signal.addEventListener('abort', abortHandler); + } + try { + while (!done) { + if (aborted) { + throw new DOMException('Aborted', 'AbortError'); + } + const { value, done: streamDone } = await reader.read(); + if (value) { + chunks.push(value); + } + done = streamDone; + } + } finally { + if (signal && abortHandler) { + signal.removeEventListener('abort', abortHandler); + } + } + if (aborted) { + throw new DOMException('Aborted', 'AbortError'); + } + const blob = new Blob(chunks); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + setTimeout(() => { + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, 0); +} diff --git a/web-app/tests/tests/stream-saver.spec.ts b/web-app/tests/tests/stream-saver.spec.ts new file mode 100644 index 000000000..2f5e49691 --- /dev/null +++ b/web-app/tests/tests/stream-saver.spec.ts @@ -0,0 +1,32 @@ +import { test, expect, type Page } from '@playwright/test'; +import fs from 'node:fs'; +import { authorize } from './acts.js'; +import { downloadReadableStream } from '../../src/utils/download-readable-stream.js'; + +test.beforeEach(async ({ page }) => { + page.on('pageerror', (err) => { + console.error(err); + }); + page.on('console', (message) => { + console.log(message); + }); +}); + +test('Download readable stream', async ({ page }) => { + await authorize(page); + const oneGig = new Uint8Array(1024 * 1024 * 1024); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(oneGig); + controller.close(); + }, + }); + + let error; + try { + await downloadReadableStream(stream, 'file.txt'); + } catch (e) { + error = e; + } + expect(error).toBeUndefined(); +});