From 410747358676f7560d4d046379186a2862f4cbfb Mon Sep 17 00:00:00 2001 From: stephanbuettig Date: Sat, 11 Apr 2026 08:37:33 +0200 Subject: [PATCH 1/5] feat: Add bulk ZIP export (#867) Adds ZIP archive export for HTTP exchanges with 37 code snippet formats via @httptoolkit/httpsnippet. Includes format picker panel, Web Worker generation, and safe filename conventions. Features: - ZIP export with selectable snippet formats (37 languages/clients) - Format picker with category grouping and popular defaults - Web Worker-based generation for non-blocking UI - Safe filename conventions matching existing HAR export pattern New files: snippet-formats registry, export-filenames utility, download helper, zip-metadata model, zip-download-panel component. Unit tests for snippet-formats and export-filenames included. Extracted from #219 as requested by @pimterry. --- package.json | 1 + src/components/view/http/http-export-card.tsx | 69 ++- src/components/view/zip-download-panel.tsx | 443 ++++++++++++++++++ src/model/ui/snippet-formats.ts | 318 +++++++++++++ src/model/ui/ui-store.ts | 20 + src/model/ui/zip-metadata.ts | 41 ++ src/services/ui-worker-api.ts | 112 ++++- src/services/ui-worker.ts | 201 +++++++- src/util/download.ts | 25 + src/util/export-filenames.ts | 111 +++++ test/unit/model/ui/snippet-formats.spec.ts | 118 +++++ test/unit/util/export-filenames.spec.ts | 111 +++++ 12 files changed, 1558 insertions(+), 12 deletions(-) create mode 100644 src/components/view/zip-download-panel.tsx create mode 100644 src/model/ui/snippet-formats.ts create mode 100644 src/model/ui/zip-metadata.ts create mode 100644 src/util/download.ts create mode 100644 src/util/export-filenames.ts create mode 100644 test/unit/model/ui/snippet-formats.spec.ts create mode 100644 test/unit/util/export-filenames.spec.ts diff --git a/package.json b/package.json index a5ad9ee8..0321228c 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "dompurify": "^3.3.3", "fast-json-patch": "^3.1.1", "fast-xml-parser": "^5.5.7", + "fflate": "^0.8.2", "graphql": "^15.8.0", "har-validator": "^5.1.3", "http-encoding": "^2.0.1", diff --git a/src/components/view/http/http-export-card.tsx b/src/components/view/http/http-export-card.tsx index 8ae3c4f0..290bf509 100644 --- a/src/components/view/http/http-export-card.tsx +++ b/src/components/view/http/http-export-card.tsx @@ -1,3 +1,4 @@ +import * as _ from 'lodash'; import React from "react"; import { action, computed } from "mobx"; import { inject, observer } from "mobx-react"; @@ -20,6 +21,8 @@ import { snippetExportOptions, SnippetOption } from '../../../model/ui/export'; +import { ZIP_ALL_FORMAT_KEY } from '../../../model/ui/snippet-formats'; +import { ZipDownloadPanel } from '../zip-download-panel'; import { ProHeaderPill, CardSalesPitch } from '../../account/pro-placeholders'; import { @@ -135,6 +138,32 @@ const ExportHarPill = styled(observer((p: { margin-right: auto; `; +// Virtual SnippetOption used as the PillSelector value when ZIP is selected. +// This is never passed to httpsnippet — it's only used for dropdown rendering. +const ZIP_SNIPPET_OPTION: SnippetOption = { + target: ZIP_ALL_FORMAT_KEY as any, + client: '' as any, + name: 'ZIP (Selected Formats)', + description: 'Download selected code snippet formats in a single ZIP archive', + link: '' +}; + +// Build extended optGroups with ZIP at the top +const exportOptionsWithZip: _.Dictionary = { + 'Archive': [ZIP_SNIPPET_OPTION], + ...snippetExportOptions +}; + +const getExportFormatKey = (option: SnippetOption): string => { + if (option === ZIP_SNIPPET_OPTION) return ZIP_ALL_FORMAT_KEY; + return getCodeSnippetFormatKey(option); +}; + +const getExportFormatName = (option: SnippetOption): string => { + if (option === ZIP_SNIPPET_OPTION) return ZIP_SNIPPET_OPTION.name; + return getCodeSnippetFormatName(option); +}; + @inject('accountStore') @inject('uiStore') @observer @@ -143,6 +172,7 @@ export class HttpExportCard extends React.Component { render() { const { exchange, accountStore } = this.props; const isPaidUser = accountStore!.user.isPaidUser(); + const isZipSelected = this.isZipSelected; return
@@ -153,10 +183,10 @@ export class HttpExportCard extends React.Component { onChange={this.setSnippetOption} - value={this.snippetOption} - optGroups={snippetExportOptions} - keyFormatter={getCodeSnippetFormatKey} - nameFormatter={getCodeSnippetFormatName} + value={this.currentDropdownValue} + optGroups={exportOptionsWithZip} + keyFormatter={getExportFormatKey} + nameFormatter={getExportFormatName} /> @@ -166,10 +196,13 @@ export class HttpExportCard extends React.Component { { isPaidUser ?
- + { isZipSelected + ? + : + }
: @@ -188,11 +221,29 @@ export class HttpExportCard extends React.Component { ; } + @computed + private get isZipSelected(): boolean { + return (this.props.uiStore!.exportSnippetFormat || '') === ZIP_ALL_FORMAT_KEY; + } + + @computed + private get currentDropdownValue(): SnippetOption { + if (this.isZipSelected) return ZIP_SNIPPET_OPTION; + return this.snippetOption; + } + @computed private get snippetOption(): SnippetOption { let exportSnippetFormat = this.props.uiStore!.exportSnippetFormat || DEFAULT_SNIPPET_FORMAT_KEY; - return getCodeSnippetOptionFromKey(exportSnippetFormat); + // If ZIP is selected, fall back to default for the snippet option + if (exportSnippetFormat === ZIP_ALL_FORMAT_KEY) { + exportSnippetFormat = DEFAULT_SNIPPET_FORMAT_KEY; + } + // Guard: if the format key doesn't resolve (e.g. deleted/invalid key), + // fall back to the default cURL option + return getCodeSnippetOptionFromKey(exportSnippetFormat) + ?? getCodeSnippetOptionFromKey(DEFAULT_SNIPPET_FORMAT_KEY); } @action.bound diff --git a/src/components/view/zip-download-panel.tsx b/src/components/view/zip-download-panel.tsx new file mode 100644 index 00000000..20c6794d --- /dev/null +++ b/src/components/view/zip-download-panel.tsx @@ -0,0 +1,443 @@ +import * as React from 'react'; +import { inject } from 'mobx-react'; +import { observer } from 'mobx-react-lite'; +import { toJS } from 'mobx'; + +import { styled, css } from '../../styles'; +import { Icon } from '../../icons'; + +import { HttpExchangeView } from '../../types'; +import { UiStore } from '../../model/ui/ui-store'; +import { generateHar } from '../../model/http/har'; +import { buildZipMetadata } from '../../model/ui/zip-metadata'; +import { + ALL_SNIPPET_FORMATS, + FORMAT_CATEGORIES, + FORMATS_BY_CATEGORY, + DEFAULT_SELECTED_FORMAT_IDS, + ALL_FORMAT_IDS, + resolveFormats +} from '../../model/ui/snippet-formats'; +import { generateZipInWorker, ZipProgressInfo } from '../../services/ui-worker-api'; +import { downloadBlob } from '../../util/download'; +import { buildZipArchiveName } from '../../util/export-filenames'; +import { logError } from '../../errors'; + +type ZipPanelState = 'idle' | 'generating' | 'error'; + +interface ZipDownloadPanelProps { + exchanges: HttpExchangeView[]; + uiStore?: UiStore; +} + +// ── Styled components ──────────────────────────────────────────────────────── + +const PanelContainer = styled.div` + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px 20px; +`; + +const FormatPickerContainer = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + max-height: 320px; + overflow-y: auto; + border: 1px solid ${p => p.theme.containerBorder}; + border-radius: 4px; + padding: 10px 12px; + background: ${p => p.theme.mainLowlightBackground}; +`; + +const PickerHeader = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 0 8px 0; + margin: 0 -12px 4px -12px; + padding-left: 12px; + padding-right: 12px; + border-bottom: 1px solid ${p => p.theme.containerBorder}; + position: sticky; + top: -10px; + z-index: 1; + background: ${p => p.theme.mainLowlightBackground}; + padding-top: 10px; +`; + +const PickerTitle = styled.span` + font-weight: 600; + font-size: ${p => p.theme.textSize}; + color: ${p => p.theme.mainColor}; +`; + +const PickerActions = styled.div` + display: flex; + gap: 8px; +`; + +const PickerActionLink = styled.button` + background: none; + border: none; + color: ${p => p.theme.popColor}; + font-size: 12px; + cursor: pointer; + padding: 0; + text-decoration: underline; + opacity: 0.85; + &:hover { opacity: 1; } +`; + +const CategoryGroup = styled.div` + margin-bottom: 4px; +`; + +const CategoryHeader = styled.div.attrs({ role: 'button', tabIndex: 0 })` + display: flex; + align-items: center; + gap: 6px; + padding: 4px 0; + cursor: pointer; + user-select: none; + + &:hover { opacity: 0.8; } + &:focus-visible { outline: 2px solid ${p => p.theme.popColor}; outline-offset: 1px; border-radius: 2px; } +`; + +const CategoryLabel = styled.span` + font-weight: 600; + font-size: 12px; + color: ${p => p.theme.mainColor}; + opacity: 0.7; + text-transform: uppercase; + letter-spacing: 0.5px; +`; + +const CategoryCount = styled.span` + font-size: 11px; + color: ${p => p.theme.mainColor}; + opacity: 0.4; +`; + +const FormatList = styled.div` + display: flex; + flex-wrap: wrap; + gap: 4px 12px; + padding-left: 4px; + margin-bottom: 4px; +`; + +const FormatCheckbox = styled.label<{ isChecked: boolean }>` + display: flex; + align-items: center; + gap: 5px; + padding: 2px 4px; + border-radius: 3px; + cursor: pointer; + font-size: ${p => p.theme.textSize}; + color: ${p => p.theme.mainColor}; + min-width: 130px; + transition: background-color 0.1s; + + ${p => p.isChecked && css` + color: ${p.theme.popColor}; + `} + + &:hover { + background: ${p => p.theme.containerBackground}; + } + + input { + accent-color: ${p => p.theme.popColor}; + cursor: pointer; + } +`; + +const BottomBar = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +`; + +const SelectionSummary = styled.span` + font-size: ${p => p.theme.textSize}; + color: ${p => p.theme.mainColor}; + opacity: 0.6; +`; + +const DownloadButton = styled.button` + padding: 10px 20px; + background: ${p => p.theme.popColor}; + color: ${p => p.theme.mainBackground}; + border: none; + border-radius: 4px; + font-size: ${p => p.theme.textSize}; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + white-space: nowrap; + + &:hover { opacity: 0.9; } + &:disabled { opacity: 0.5; cursor: not-allowed; } +`; + +const ProgressContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 30px 20px; + gap: 12px; +`; + +const ProgressBar = styled.div` + width: 100%; + max-width: 300px; + height: 6px; + background: ${p => p.theme.containerBorder}; + border-radius: 3px; + overflow: hidden; +`; + +const ProgressFill = styled.div<{ percent: number }>` + height: 100%; + width: ${p => p.percent}%; + background: ${p => p.theme.popColor}; + border-radius: 3px; + transition: width 0.2s ease; +`; + +const StatusText = styled.p` + color: ${p => p.theme.mainColor}; + font-size: ${p => p.theme.textSize}; + opacity: 0.7; + text-align: center; + margin: 0; +`; + +const ErrorText = styled(StatusText)` + color: ${p => p.theme.warningColor}; + opacity: 1; +`; + +const WarningText = styled.span` + font-size: 12px; + color: ${p => p.theme.warningColor}; + opacity: 0.85; +`; + +const RetryButton = styled(DownloadButton)` + padding: 8px 16px; +`; + +// ── Component ──────────────────────────────────────────────────────────────── + +export const ZipDownloadPanel = inject('uiStore')(observer((props: ZipDownloadPanelProps) => { + const { exchanges, uiStore } = props; + const [state, setState] = React.useState('idle'); + const [errorMsg, setErrorMsg] = React.useState(null); + const [progress, setProgress] = React.useState(null); + + // Format selection lives in UiStore — shared with batch toolbar + const selectedIds = uiStore!.zipFormatIds; + + // Guard against setState on unmounted component + const mountedRef = React.useRef(true); + React.useEffect(() => { + return () => { mountedRef.current = false; }; + }, []); + + // ── Selection helpers (mutate UiStore directly) ───────────────────── + + const toggleFormat = React.useCallback((id: string) => { + const next = new Set(selectedIds); + if (next.has(id)) next.delete(id); else next.add(id); + uiStore!.setZipFormatIds(next); + }, [selectedIds, uiStore]); + + const toggleCategory = React.useCallback((category: string) => { + const categoryFormats = FORMATS_BY_CATEGORY[category] || []; + const next = new Set(selectedIds); + const allSelected = categoryFormats.every(f => selectedIds.has(f.id)); + categoryFormats.forEach(f => { + if (allSelected) next.delete(f.id); else next.add(f.id); + }); + uiStore!.setZipFormatIds(next); + }, [selectedIds, uiStore]); + + const selectAll = React.useCallback(() => { + uiStore!.setZipFormatIds(ALL_FORMAT_IDS); + }, [uiStore]); + + const selectPopular = React.useCallback(() => { + uiStore!.setZipFormatIds(DEFAULT_SELECTED_FORMAT_IDS); + }, [uiStore]); + + const selectNone = React.useCallback(() => { + uiStore!.setZipFormatIds([]); + }, [uiStore]); + + // ── Download handler ───────────────────────────────────────────────── + + const handleDownload = React.useCallback(async () => { + setState('generating'); + setErrorMsg(null); + setProgress(null); + try { + const snapshotExchanges = exchanges.slice(); + if (snapshotExchanges.length === 0) { + setState('idle'); + return; + } + + const formats = resolveFormats(selectedIds); + if (formats.length === 0) { + throw new Error('No formats selected'); + } + + const har = await generateHar(snapshotExchanges); + const harEntries = toJS(har.log.entries); + const metadata = buildZipMetadata(snapshotExchanges.length, formats); + + const result = await generateZipInWorker( + harEntries, + formats, + metadata, + (info) => { if (mountedRef.current) setProgress(info); } + ); + + if (!mountedRef.current) return; + + const blob = new Blob([result.buffer], { type: 'application/zip' }); + downloadBlob(blob, buildZipArchiveName(snapshotExchanges.length)); + + if (result.snippetErrors > 0) { + setErrorMsg( + `ZIP saved. ${result.snippetErrors} of ${result.totalSnippets} snippets failed (see _errors.json).` + ); + } + setState('idle'); + setProgress(null); + } catch (err) { + logError(err); + if (!mountedRef.current) return; + setErrorMsg(err instanceof Error ? err.message : 'ZIP generation failed'); + setState('error'); + setProgress(null); + } + }, [exchanges, selectedIds]); + + // ── Render: Generating state ───────────────────────────────────────── + + if (state === 'generating') { + const percent = progress?.percent ?? 0; + const formatCount = selectedIds.size; + + return + + + Generating {formatCount} format{formatCount !== 1 ? 's' : ''} for{' '} + {exchanges.length} exchange{exchanges.length !== 1 ? 's' : ''} + {percent > 0 ? ` — ${percent}%` : '...'} + + {percent > 0 && ( + + + + )} + ; + } + + // ── Render: Error state ────────────────────────────────────────────── + + if (state === 'error') { + return + {errorMsg} + setState('idle')}> + Retry + + ; + } + + // ── Render: Idle state (format picker + download) ──────────────────── + + const totalFormats = ALL_SNIPPET_FORMATS.length; + const selectedCount = selectedIds.size; + + return + + + + Snippet Formats + + + All ({totalFormats}) + Popular + None + + + + {FORMAT_CATEGORIES.map(category => { + const formats = FORMATS_BY_CATEGORY[category]; + const catSelected = formats.filter(f => selectedIds.has(f.id)).length; + return + toggleCategory(category)} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleCategory(category); + } + }} + aria-label={`Toggle all ${category} formats (${catSelected}/${formats.length} selected)`} + > + {category} + + {catSelected}/{formats.length} + + + + {formats.map(fmt => ( + + toggleFormat(fmt.id)} + /> + {fmt.label} + + ))} + + ; + })} + + + +
+ + {selectedCount} of {totalFormats} formats selected + + {errorMsg && <>
{errorMsg}} +
+ + + Download ZIP ({exchanges.length} exchange{exchanges.length !== 1 ? 's' : ''}) + +
+
; +})); diff --git a/src/model/ui/snippet-formats.ts b/src/model/ui/snippet-formats.ts new file mode 100644 index 00000000..67179fd1 --- /dev/null +++ b/src/model/ui/snippet-formats.ts @@ -0,0 +1,318 @@ +/** + * Snippet format registry — single source of truth for all export formats. + * + * Contains ALL available HTTPSnippet targets/clients organized by language + * category. The ZIP export pipeline, format picker UI, and batch toolbar + * all consume this registry. + */ +import * as HTTPSnippet from '@httptoolkit/httpsnippet'; + +// ── Sentinel key for the "ZIP (Selected Formats)" meta-option ─────────────── +export const ZIP_ALL_FORMAT_KEY = '__zip_all__' as const; + +// ── Format definition used by the ZIP generation pipeline ──────────────────── +export interface SnippetFormatDefinition { + /** Unique ID, e.g. 'shell_curl' */ + id: string; + /** Language category for grouping in the format picker */ + category: string; + /** Folder name inside the ZIP archive */ + folderName: string; + /** File extension for generated snippets */ + extension: string; + /** httpsnippet target identifier */ + target: HTTPSnippet.Target; + /** httpsnippet client identifier */ + client: HTTPSnippet.Client; + /** Human-readable label */ + label: string; + /** Whether this is a "popular" format (pre-checked in format picker) */ + popular: boolean; +} + +/** + * Complete registry of all HTTPSnippet-supported formats. + * Organized by language category for clean grouping in the UI. + */ +export const ALL_SNIPPET_FORMATS: SnippetFormatDefinition[] = [ + // ── Shell ──────────────────────────────────────────────────────────── + { + id: 'shell_curl', category: 'Shell', folderName: 'shell-curl', + extension: 'sh', target: 'shell', client: 'curl', + label: 'cURL', popular: true + }, + { + id: 'shell_httpie', category: 'Shell', folderName: 'shell-httpie', + extension: 'sh', target: 'shell', client: 'httpie', + label: 'HTTPie', popular: true + }, + { + id: 'shell_wget', category: 'Shell', folderName: 'shell-wget', + extension: 'sh', target: 'shell', client: 'wget', + label: 'Wget', popular: false + }, + + // ── JavaScript (Browser) ───────────────────────────────────────────── + { + id: 'javascript_fetch', category: 'JavaScript', folderName: 'js-fetch', + extension: 'js', target: 'javascript', client: 'fetch', + label: 'Fetch API', popular: true + }, + { + id: 'javascript_xhr', category: 'JavaScript', folderName: 'js-xhr', + extension: 'js', target: 'javascript', client: 'xhr', + label: 'XMLHttpRequest', popular: false + }, + { + id: 'javascript_jquery', category: 'JavaScript', folderName: 'js-jquery', + extension: 'js', target: 'javascript', client: 'jquery', + label: 'jQuery', popular: false + }, + { + id: 'javascript_axios', category: 'JavaScript', folderName: 'js-axios', + extension: 'js', target: 'javascript', client: 'axios', + label: 'Axios', popular: false + }, + + // ── Node.js ────────────────────────────────────────────────────────── + { + id: 'node_fetch', category: 'Node.js', folderName: 'node-fetch', + extension: 'js', target: 'node', client: 'fetch', + label: 'node-fetch', popular: false + }, + { + id: 'node_axios', category: 'Node.js', folderName: 'node-axios', + extension: 'js', target: 'node', client: 'axios', + label: 'Axios', popular: true + }, + { + id: 'node_native', category: 'Node.js', folderName: 'node-http', + extension: 'js', target: 'node', client: 'native', + label: 'HTTP module', popular: false + }, + { + id: 'node_request', category: 'Node.js', folderName: 'node-request', + extension: 'js', target: 'node', client: 'request', + label: 'Request', popular: false + }, + { + id: 'node_unirest', category: 'Node.js', folderName: 'node-unirest', + extension: 'js', target: 'node', client: 'unirest', + label: 'Unirest', popular: false + }, + + // ── Python ─────────────────────────────────────────────────────────── + { + id: 'python_requests', category: 'Python', folderName: 'python-requests', + extension: 'py', target: 'python', client: 'requests', + label: 'Requests', popular: true + }, + { + id: 'python_python3', category: 'Python', folderName: 'python-http', + extension: 'py', target: 'python', client: 'python3', + label: 'http.client', popular: false + }, + + // ── Java ───────────────────────────────────────────────────────────── + { + id: 'java_okhttp', category: 'Java', folderName: 'java-okhttp', + extension: 'java', target: 'java', client: 'okhttp', + label: 'OkHttp', popular: true + }, + { + id: 'java_unirest', category: 'Java', folderName: 'java-unirest', + extension: 'java', target: 'java', client: 'unirest', + label: 'Unirest', popular: false + }, + { + id: 'java_asynchttp', category: 'Java', folderName: 'java-asynchttp', + extension: 'java', target: 'java', client: 'asynchttp', + label: 'AsyncHttp', popular: false + }, + { + id: 'java_nethttp', category: 'Java', folderName: 'java-nethttp', + extension: 'java', target: 'java', client: 'nethttp', + label: 'HttpClient', popular: false + }, + + // ── Kotlin ─────────────────────────────────────────────────────────── + { + id: 'kotlin_okhttp', category: 'Kotlin', folderName: 'kotlin-okhttp', + extension: 'kt', target: 'kotlin' as HTTPSnippet.Target, client: 'okhttp', + label: 'OkHttp', popular: false + }, + + // ── C# ─────────────────────────────────────────────────────────────── + { + id: 'csharp_restsharp', category: 'C#', folderName: 'csharp-restsharp', + extension: 'cs', target: 'csharp', client: 'restsharp', + label: 'RestSharp', popular: false + }, + { + id: 'csharp_httpclient', category: 'C#', folderName: 'csharp-httpclient', + extension: 'cs', target: 'csharp', client: 'httpclient', + label: 'HttpClient', popular: false + }, + + // ── Go ─────────────────────────────────────────────────────────────── + { + id: 'go_native', category: 'Go', folderName: 'go-native', + extension: 'go', target: 'go', client: 'native', + label: 'net/http', popular: false + }, + + // ── PHP ────────────────────────────────────────────────────────────── + { + id: 'php_curl', category: 'PHP', folderName: 'php-curl', + extension: 'php', target: 'php', client: 'curl', + label: 'ext-cURL', popular: false + }, + { + id: 'php_http1', category: 'PHP', folderName: 'php-http1', + extension: 'php', target: 'php', client: 'http1', + label: 'HTTP v1', popular: false + }, + { + id: 'php_http2', category: 'PHP', folderName: 'php-http2', + extension: 'php', target: 'php', client: 'http2', + label: 'HTTP v2', popular: false + }, + + // ── Ruby ───────────────────────────────────────────────────────────── + { + id: 'ruby_native', category: 'Ruby', folderName: 'ruby-native', + extension: 'rb', target: 'ruby', client: 'native', + label: 'Net::HTTP', popular: false + }, + { + id: 'ruby_faraday', category: 'Ruby', folderName: 'ruby-faraday', + extension: 'rb', target: 'ruby', client: 'faraday', + label: 'Faraday', popular: false + }, + + // ── Rust ───────────────────────────────────────────────────────────── + { + id: 'rust_reqwest', category: 'Rust', folderName: 'rust-reqwest', + extension: 'rs', target: 'rust' as HTTPSnippet.Target, client: 'reqwest', + label: 'reqwest', popular: false + }, + + // ── Swift ──────────────────────────────────────────────────────────── + { + id: 'swift_nsurlsession', category: 'Swift', folderName: 'swift-nsurlsession', + extension: 'swift', target: 'swift', client: 'nsurlsession', + label: 'URLSession', popular: false + }, + + // ── Objective-C ────────────────────────────────────────────────────── + { + id: 'objc_nsurlsession', category: 'Objective-C', folderName: 'objc-nsurlsession', + extension: 'm', target: 'objc', client: 'nsurlsession', + label: 'NSURLSession', popular: false + }, + + // ── C ──────────────────────────────────────────────────────────────── + { + id: 'c_libcurl', category: 'C', folderName: 'c-libcurl', + extension: 'c', target: 'c', client: 'libcurl', + label: 'libcurl', popular: false + }, + + // ── R ──────────────────────────────────────────────────────────────── + { + id: 'r_httr', category: 'R', folderName: 'r-httr', + extension: 'r', target: 'r' as HTTPSnippet.Target, client: 'httr', + label: 'httr', popular: false + }, + + // ── OCaml ──────────────────────────────────────────────────────────── + { + id: 'ocaml_cohttp', category: 'OCaml', folderName: 'ocaml-cohttp', + extension: 'ml', target: 'ocaml', client: 'cohttp', + label: 'CoHTTP', popular: false + }, + + // ── Clojure ────────────────────────────────────────────────────────── + { + id: 'clojure_clj_http', category: 'Clojure', folderName: 'clojure-clj_http', + extension: 'clj', target: 'clojure', client: 'clj_http', + label: 'clj-http', popular: false + }, + + // ── Crystal ────────────────────────────────────────────────────────── + // Note: Crystal target may not be available in all httpsnippet versions + // { + // id: 'crystal_native', category: 'Crystal', folderName: 'crystal-native', + // extension: 'cr', target: 'crystal' as any, client: 'native' as any, + // label: 'HTTP::Client', popular: false + // }, + + // ── PowerShell ─────────────────────────────────────────────────────── + { + id: 'powershell_webrequest', category: 'PowerShell', folderName: 'powershell-webrequest', + extension: 'ps1', target: 'powershell', client: 'webrequest', + label: 'Invoke-WebRequest', popular: true + }, + { + id: 'powershell_restmethod', category: 'PowerShell', folderName: 'powershell-restmethod', + extension: 'ps1', target: 'powershell', client: 'restmethod', + label: 'Invoke-RestMethod', popular: false + }, + + // ── HTTP ───────────────────────────────────────────────────────────── + { + id: 'http_1.1', category: 'HTTP', folderName: 'http-raw', + extension: 'txt', target: 'http' as HTTPSnippet.Target, client: '1.1', + label: 'Raw HTTP/1.1', popular: false + }, + + // ── Java (RestAssured) ─────────────────────────────────────────────── + // Available in some httpsnippet forks/versions: + // { + // id: 'java_restclient', category: 'Java', folderName: 'java-restclient', + // extension: 'java', target: 'java', client: 'restclient' as any, + // label: 'RestClient', popular: false + // }, +]; + +/** + * Extract unique categories in their original insertion order. + */ +export const FORMAT_CATEGORIES: string[] = [ + ...new Set(ALL_SNIPPET_FORMATS.map(f => f.category)) +]; + +/** + * Formats grouped by category for UI rendering. + */ +export const FORMATS_BY_CATEGORY: Record = + ALL_SNIPPET_FORMATS.reduce((acc, fmt) => { + (acc[fmt.category] ??= []).push(fmt); + return acc; + }, {} as Record); + +/** + * Default set of format IDs pre-selected in the format picker. + * These are the "popular" formats that most developers use. + */ +export const DEFAULT_SELECTED_FORMAT_IDS: ReadonlySet = new Set( + ALL_SNIPPET_FORMATS.filter(f => f.popular).map(f => f.id) +); + +/** All format IDs as a set (for "select all") */ +export const ALL_FORMAT_IDS: ReadonlySet = new Set( + ALL_SNIPPET_FORMATS.map(f => f.id) +); + +/** Quick lookup by format ID */ +export const FORMAT_BY_ID: ReadonlyMap = new Map( + ALL_SNIPPET_FORMATS.map(f => [f.id, f]) +); + +/** + * Resolve a set of format IDs to their full definitions. + * Silently skips unknown IDs. + */ +export function resolveFormats(ids: ReadonlySet): SnippetFormatDefinition[] { + return ALL_SNIPPET_FORMATS.filter(f => ids.has(f.id)); +} diff --git a/src/model/ui/ui-store.ts b/src/model/ui/ui-store.ts index 143dd33e..d267d9bd 100644 --- a/src/model/ui/ui-store.ts +++ b/src/model/ui/ui-store.ts @@ -8,6 +8,7 @@ import { persist, hydrate } from '../../util/mobx-persist/persist'; import { unreachableCheck, UnreachableCheck } from '../../util/error'; import { AccountStore } from '../account/account-store'; +import { DEFAULT_SELECTED_FORMAT_IDS } from './snippet-formats'; import { emptyFilterSet, FilterSet } from '../filters/search-filters'; import { DesktopApi } from '../../services/desktop-api'; import { @@ -461,6 +462,25 @@ export class UiStore { @persist @observable exportSnippetFormat: string | undefined; + /** + * Persisted list of snippet format IDs selected for ZIP export. + * Shared between the Export card (single exchange) and the batch toolbar + * (multi-select), so the user's choice is consistent everywhere. + * Initialized with popular defaults; updated via setZipFormatIds(). + */ + @persist('list') @observable + _zipFormatIds: string[] = [...DEFAULT_SELECTED_FORMAT_IDS]; + + @computed + get zipFormatIds(): ReadonlySet { + return new Set(this._zipFormatIds); + } + + @action.bound + setZipFormatIds(ids: ReadonlySet | string[]) { + this._zipFormatIds = Array.isArray(ids) ? [...ids] : [...ids]; + } + // Actions for persisting view state when switching tabs @action.bound setViewScrollPosition(position: number | 'end') { diff --git a/src/model/ui/zip-metadata.ts b/src/model/ui/zip-metadata.ts new file mode 100644 index 00000000..2f73c347 --- /dev/null +++ b/src/model/ui/zip-metadata.ts @@ -0,0 +1,41 @@ +/** + * Metadata schema and builder for _metadata.json inside ZIP exports. + */ +import { UI_VERSION } from '../../services/service-versions'; +import type { SnippetFormatDefinition } from './snippet-formats'; + +export interface ZipMetadata { + /** ISO 8601 timestamp of the export */ + exportedAt: string; + /** Number of HTTP exchanges included */ + exchangeCount: number; + /** HTTP Toolkit UI version string */ + httptoolkitVersion: string; + /** List of format folder names included in the archive */ + formats: string[]; + /** Explains the archive structure to users who open _metadata.json */ + contents: { + snippetFolders: string; + harFile: string; + }; +} + +/** + * Builds the metadata object for the ZIP archive. + * This is serialized as `_metadata.json` at the root of the archive. + */ +export function buildZipMetadata( + exchangeCount: number, + formats: SnippetFormatDefinition[] +): ZipMetadata { + return { + exportedAt: new Date().toISOString(), + exchangeCount, + httptoolkitVersion: UI_VERSION, + formats: formats.map(f => f.folderName), + contents: { + snippetFolders: 'Each folder contains code snippets to reproduce the requests (request only)', + harFile: 'The .har file contains the full network traffic: requests AND responses with headers, bodies, and timings' + } + }; +} diff --git a/src/services/ui-worker-api.ts b/src/services/ui-worker-api.ts index 7185032b..5c04e770 100644 --- a/src/services/ui-worker-api.ts +++ b/src/services/ui-worker-api.ts @@ -18,11 +18,15 @@ import type { FormatRequest, FormatResponse, ParseCertRequest, - ParseCertResponse + ParseCertResponse, + GenerateZipRequest, + GenerateZipResponse } from './ui-worker'; import { Headers, Omit } from '../types'; import type { ApiMetadata, ApiSpec } from '../model/api/api-interfaces'; +import type { SnippetFormatDefinition } from '../model/ui/snippet-formats'; +import type { ZipMetadata } from '../model/ui/zip-metadata'; import { WorkerFormatterKey } from './ui-worker-formatters'; import { decodingRequired } from '../model/events/bodies'; @@ -153,4 +157,110 @@ export async function formatBufferAsync(buffer: Buffer, format: WorkerFormatterK format, headers, })).formatted; +} + +export interface ZipProgressInfo { + phase: string; + completed: number; + total: number; + percent: number; +} + +export interface ZipResult { + buffer: ArrayBuffer; + /** Number of snippet generations that failed (see _errors.json in the archive) */ + snippetErrors: number; + /** Total number of snippet generations attempted */ + totalSnippets: number; +} + +/** + * Generates a ZIP archive containing code snippets in all formats plus + * the HAR data and metadata. All CPU-intensive work runs in the Web Worker. + * + * @param harEntries - Plain (non-MobX-proxy) HAR entry objects. Use toJS() before calling. + * @param formats - Which snippet formats to include. + * @param metadata - The ZipMetadata object for _metadata.json. + * @param onProgress - Optional callback invoked with progress updates (~every 5%). + * @returns ZipResult with the compressed archive buffer and snippet error counts. + */ +export function generateZipInWorker( + harEntries: any[], + formats: SnippetFormatDefinition[], + metadata: ZipMetadata, + onProgress?: (info: ZipProgressInfo) => void +): Promise { + if (harEntries.length === 0) { + return Promise.reject(new Error('No entries to export')); + } + if (formats.length === 0) { + return Promise.reject(new Error('No formats selected')); + } + + const id = getId(); + + return new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => { + worker.removeEventListener('message', handler); + clearTimeout(timeoutId); + }; + + // Safety timeout: if the worker doesn't respond within 5 minutes, + // clean up the listener to prevent memory leaks. + const timeoutId = setTimeout(() => { + if (!settled) { + settled = true; + cleanup(); + reject(new Error('ZIP generation timed out after 5 minutes')); + } + }, 5 * 60 * 1000); + + const handler = (event: MessageEvent) => { + const data = event.data; + if (data.id !== id) return; + + // Progress messages have a 'type' field; final responses don't + if (data.type === 'generateZipProgress') { + onProgress?.({ + phase: data.phase, + completed: data.completed, + total: data.total, + percent: data.percent + }); + return; // Keep listening for the final result + } + + // Final result — always remove listener before resolving/rejecting + if (settled) return; + settled = true; + cleanup(); + + if (data.error) { + reject(deserializeError(data.error)); + } else { + resolve({ + buffer: data.buffer, + snippetErrors: data.snippetErrors || 0, + totalSnippets: data.totalSnippets || 0 + }); + } + }; + + worker.addEventListener('message', handler); + + try { + worker.postMessage(Object.assign({ id }, { + type: 'generateZip', + harEntries, + formats, + metadata + } as Omit)); + } catch (err) { + // postMessage can throw for unserializable data (MobX proxies, etc.) + settled = true; + cleanup(); + reject(err); + } + }); } \ No newline at end of file diff --git a/src/services/ui-worker.ts b/src/services/ui-worker.ts index c25f86b7..277e0880 100644 --- a/src/services/ui-worker.ts +++ b/src/services/ui-worker.ts @@ -12,12 +12,17 @@ import { SUPPORTED_ENCODING } from 'http-encoding'; import { OpenAPIObject } from 'openapi-directory'; +import * as HTTPSnippet from '@httptoolkit/httpsnippet'; +import { zip } from 'fflate'; import { Headers } from '../types'; import { ApiMetadata, ApiSpec } from '../model/api/api-interfaces'; import { buildOpenApiMetadata, buildOpenRpcMetadata } from '../model/api/build-api-metadata'; import { parseCert, ParsedCertificate, validatePKCS12, ValidationResult } from '../model/crypto'; import { WorkerFormatterKey, formatBuffer } from './ui-worker-formatters'; +import { buildZipFileName } from '../util/export-filenames'; +import type { SnippetFormatDefinition } from '../model/ui/snippet-formats'; +import type { ZipMetadata } from '../model/ui/zip-metadata'; interface Message { id: number; @@ -100,6 +105,18 @@ export interface FormatResponse extends Message { formatted: string; } +export interface GenerateZipRequest extends Message { + type: 'generateZip'; + harEntries: any[]; + formats: SnippetFormatDefinition[]; + metadata: ZipMetadata; +} + +export interface GenerateZipResponse extends Message { + error?: Error; + buffer: ArrayBuffer; +} + export type BackgroundRequest = | DecodeRequest | EncodeRequest @@ -107,7 +124,8 @@ export type BackgroundRequest = | BuildApiRequest | ValidatePKCSRequest | ParseCertRequest - | FormatRequest; + | FormatRequest + | GenerateZipRequest; export type BackgroundResponse = | DecodeResponse @@ -116,7 +134,8 @@ export type BackgroundResponse = | BuildApiResponse | ValidatePKCSResponse | ParseCertResponse - | FormatResponse; + | FormatResponse + | GenerateZipResponse; const bufferToArrayBuffer = (buffer: Buffer): ArrayBuffer => // Have to remember to slice: this can be a view into part of a much larger buffer! @@ -228,6 +247,184 @@ ctx.addEventListener('message', async (event: { data: BackgroundRequest }) => { ctx.postMessage({ id: event.data.id, formatted }); break; + case 'generateZip': { + const { id, harEntries, formats, metadata } = event.data as GenerateZipRequest; + + try { + if (!harEntries || harEntries.length === 0) { + throw new Error('No HAR entries provided for ZIP export'); + } + if (!formats || formats.length === 0) { + throw new Error('No snippet formats selected for ZIP export'); + } + + const encoder = new TextEncoder(); + const files: Record = {}; + const totalSteps = formats.length * harEntries.length; + let completedSteps = 0; + let lastReportedPercent = 0; + + // Track snippet generation errors for transparency + const snippetErrors: Array<{ + format: string; + entryIndex: number; + method: string; + url: string; + error: string; + }> = []; + + // 1. Generate snippet files for each format × each entry + for (const format of formats) { + for (let i = 0; i < harEntries.length; i++) { + const entry = harEntries[i]; + try { + // HTTPSnippet expects a HAR *request* object, not a full + // HAR entry. Extract and simplify the request, matching + // the pattern used by generateCodeSnippet() on the main + // thread (see model/ui/export.ts). + const harRequest = entry.request; + + // Sanitize postData: httpsnippet's HAR validator rejects + // null values in postData.text (e.g. CDN beacon POSTs). + // Also strip empty queryString entries that fail validation. + const postData = harRequest.postData + ? { + ...harRequest.postData, + text: harRequest.postData.text ?? '' + } + : harRequest.postData; + + const snippetInput = { + ...harRequest, + headers: (harRequest.headers || []).filter((h: any) => + h.name.toLowerCase() !== 'content-length' && + h.name.toLowerCase() !== 'content-encoding' && + !h.name.startsWith(':') + ), + queryString: (harRequest.queryString || []).filter( + (q: any) => q.name !== '' || q.value !== '' + ), + cookies: [], // Included in headers already + ...(postData !== undefined ? { postData } : {}) + }; + const snippet = new HTTPSnippet(snippetInput); + const code = snippet.convert(format.target, format.client); + if (code) { + const filename = buildZipFileName( + i + 1, + entry.request?.method ?? 'UNKNOWN', + entry.response?.status ?? null, + format.extension, + entry.request?.url + ); + const content = Array.isArray(code) ? code[0] : code; + files[`${format.folderName}/${filename}`] = encoder.encode( + typeof content === 'string' ? content : String(content) + ); + } + } catch (snippetErr) { + // Skip this format for this entry but continue + console.warn( + `Snippet generation failed for ${format.folderName}, entry ${i}:`, + snippetErr + ); + snippetErrors.push({ + format: format.folderName, + entryIndex: i + 1, + method: entry.request?.method ?? 'UNKNOWN', + url: entry.request?.url ?? 'unknown', + error: snippetErr instanceof Error ? snippetErr.message : String(snippetErr) + }); + } + + // Report progress every 5% (avoids flooding main thread) + completedSteps++; + if (totalSteps > 0) { + const currentPercent = Math.floor((completedSteps / totalSteps) * 100); + if (currentPercent >= lastReportedPercent + 5) { + lastReportedPercent = currentPercent; + ctx.postMessage({ + id, + type: 'generateZipProgress', + phase: 'snippets', + completed: completedSteps, + total: totalSteps, + percent: currentPercent + }); + } + } + } + } + + // 2. Add full traffic capture as HAR + // Contains the complete network traffic (requests + responses + // with headers, bodies, timings, cookies) for every exchange. + // The snippets in the format folders only reproduce the request; + // this file is the authoritative record of what actually happened. + const harDocument = { + log: { + version: '1.2', + creator: { + name: 'HTTP Toolkit', + version: metadata.httptoolkitVersion + }, + entries: harEntries + } + }; + const harFileName = `HTTPToolkit_${harEntries.length}-requests_full-traffic.har`; + files[harFileName] = encoder.encode( + JSON.stringify(harDocument, null, 2) + ); + + // 3. Add _metadata.json (include error summary if any) + const metadataWithErrors = snippetErrors.length > 0 + ? { ...metadata, snippetErrors: snippetErrors.length, totalSnippets: totalSteps } + : metadata; + files['_metadata.json'] = encoder.encode( + JSON.stringify(metadataWithErrors, null, 2) + ); + + // 3b. If any snippets failed, include a detailed error log + if (snippetErrors.length > 0) { + files['_errors.json'] = encoder.encode( + JSON.stringify({ + summary: `${snippetErrors.length} of ${totalSteps} snippet generations failed`, + errors: snippetErrors + }, null, 2) + ); + } + + // 4. Compress with fflate (async callback API) + zip(files, { level: 6 }, (err, data) => { + if (err) { + ctx.postMessage({ + id, + error: serializeError(err) + }); + return; + } + // Transfer the ArrayBuffer for zero-copy. + // Include snippet error count so the UI can warn if needed. + ctx.postMessage( + { + id, + buffer: data.buffer, + snippetErrors: snippetErrors.length, + totalSnippets: totalSteps + }, + [data.buffer] + ); + }); + } catch (err) { + ctx.postMessage({ + id, + error: serializeError(err) + }); + } + // Note: response is sent asynchronously from the zip() callback above + return; + } + default: console.error('Unknown worker event', event); } diff --git a/src/util/download.ts b/src/util/download.ts new file mode 100644 index 00000000..8a5bcd60 --- /dev/null +++ b/src/util/download.ts @@ -0,0 +1,25 @@ +/** + * Download utility — triggers a browser file download from a Blob. + * + * The object URL is revoked after a generous 10-second delay to ensure + * the browser has started the download even for very large files. + * This prevents premature revocation that could abort downloads on + * slower machines or when the browser needs extra time to begin writing. + */ +export function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + try { + anchor.click(); + } finally { + document.body.removeChild(anchor); + // 10 seconds is generous enough even for very large ZIPs (>500MB) + // where the browser may need extra time to begin the write. + // Once the browser has begun writing to disk, revoking only frees + // the in-memory blob reference — it does not abort the download. + setTimeout(() => URL.revokeObjectURL(url), 10000); + } +} diff --git a/src/util/export-filenames.ts b/src/util/export-filenames.ts new file mode 100644 index 00000000..292930a4 --- /dev/null +++ b/src/util/export-filenames.ts @@ -0,0 +1,111 @@ +/** + * Utilities for generating safe, consistent filenames for exports. + * + * Naming conventions follow HTTPToolkit's established patterns: + * - HAR single: "{METHOD} {hostname}.har" + * - HAR batch: "HTTPToolkit_export_{date}_{count}-requests.har" + * - ZIP archive: "HTTPToolkit_{date}_{count}-requests.zip" + * - Snippet: "{index}_{METHOD}_{STATUS}_{hostname}.{ext}" + */ + +const MAX_FILENAME_LENGTH = 100; + +/** + * Sanitizes a string for safe use in filenames. + * Strips protocol, query strings, and invalid characters. + */ +function sanitize(raw: string, maxLen: number = 40): string { + return raw + .replace(/^https?:\/\//, '') // Strip protocol + .replace(/[?#].*$/, '') // Strip query/fragment + .replace(/\/+$/, '') // Strip trailing slashes + .replace(/[<>:"/\\|?*\x00-\x1F]/g, '_') // Replace invalid FS chars + .replace(/_+/g, '_') // Collapse multiple underscores + .slice(0, maxLen); +} + +/** + * Extracts a short, readable hostname from a URL. + * Falls back to the first path segment if the hostname is generic. + * + * Examples: + * "https://api.example.com/v2/users?page=1" → "api.example.com" + * "https://10.0.0.1:8080/health" → "10.0.0.1" + */ +function extractHost(url: string | undefined | null): string { + if (!url) return ''; + try { + const parsed = new URL(url); + // Use hostname (without port) for readability + return parsed.hostname || ''; + } catch { + // Fallback: extract something meaningful from the raw string + const match = url.match(/^https?:\/\/([^/:?#]+)/); + return match ? match[1] : ''; + } +} + +/** + * Builds a filename for a single snippet inside the ZIP archive. + * + * Convention: `{index}_{METHOD}_{STATUS}_{hostname}.{ext}` + * Examples: + * "001_GET_200_api.github.com.sh" + * "023_POST_201_httpbin.org.py" + * "007_DELETE_pending_localhost.js" + * + * The hostname gives the user immediate context about which request + * each file represents, matching HTTPToolkit's existing HAR export + * pattern of "{METHOD} {hostname}.har". + */ +export function buildZipFileName( + index: number, + method: string, + status: number | null, + extension: string, + url?: string +): string { + const padWidth = 3; + const safeIndex = String(Math.max(1, Math.floor(index))).padStart(padWidth, '0'); + const safeMethod = (method || 'UNKNOWN').toUpperCase().replace(/[^A-Z]/g, '') || 'UNKNOWN'; + const safeStatus = status != null ? String(status) : 'pending'; + const safeExt = extension.replace(/[^a-zA-Z0-9]/g, '') || 'txt'; + + const host = extractHost(url); + const safeHost = host ? '_' + sanitize(host, 30) : ''; + + const name = `${safeIndex}_${safeMethod}_${safeStatus}${safeHost}.${safeExt}`; + + // Ensure we don't exceed filesystem limits + return name.length > MAX_FILENAME_LENGTH + ? `${safeIndex}_${safeMethod}_${safeStatus}.${safeExt}` + : name; +} + +/** + * Builds the archive filename for the downloaded ZIP. + * + * Convention: `HTTPToolkit_{date}_{count}-requests.zip` + * Example: "HTTPToolkit_2026-04-04_14-30_180-requests.zip" + * + * Follows HTTPToolkit's established batch HAR naming pattern: + * "HTTPToolkit_export_{date}_{count}-requests.har" + */ +export function buildZipArchiveName(exchangeCount?: number): string { + const now = new Date(); + const date = [ + now.getFullYear(), + String(now.getMonth() + 1).padStart(2, '0'), + String(now.getDate()).padStart(2, '0') + ].join('-'); + const time = [ + String(now.getHours()).padStart(2, '0'), + String(now.getMinutes()).padStart(2, '0') + ].join('-'); + + const countPart = exchangeCount != null + ? `_${exchangeCount}-requests` + : ''; + + return `HTTPToolkit_${date}_${time}${countPart}.zip`; +} diff --git a/test/unit/model/ui/snippet-formats.spec.ts b/test/unit/model/ui/snippet-formats.spec.ts new file mode 100644 index 00000000..1639df43 --- /dev/null +++ b/test/unit/model/ui/snippet-formats.spec.ts @@ -0,0 +1,118 @@ +import { expect } from 'chai'; +import { + ZIP_ALL_FORMAT_KEY, + ALL_SNIPPET_FORMATS, + DEFAULT_SELECTED_FORMAT_IDS, + ALL_FORMAT_IDS, + FORMAT_CATEGORIES, + FORMATS_BY_CATEGORY, + FORMAT_BY_ID, + resolveFormats, + SnippetFormatDefinition +} from '../../../../src/model/ui/snippet-formats'; + +describe('snippet-formats', () => { + + describe('ZIP_ALL_FORMAT_KEY', () => { + it('is a non-empty string', () => { + expect(ZIP_ALL_FORMAT_KEY).to.be.a('string'); + expect(ZIP_ALL_FORMAT_KEY.length).to.be.greaterThan(0); + }); + + it('is not a valid httpsnippet target (sentinel)', () => { + expect(ZIP_ALL_FORMAT_KEY).to.include('__'); + }); + }); + + describe('ALL_SNIPPET_FORMATS', () => { + it('contains at least 4 formats', () => { + expect(ALL_SNIPPET_FORMATS.length).to.be.greaterThanOrEqual(4); + }); + + it('has unique IDs', () => { + const ids = ALL_SNIPPET_FORMATS.map(f => f.id); + expect(new Set(ids).size).to.equal(ids.length); + }); + + it('has unique folder names', () => { + const folders = ALL_SNIPPET_FORMATS.map(f => f.folderName); + expect(new Set(folders).size).to.equal(folders.length); + }); + + it('each format has all required fields', () => { + ALL_SNIPPET_FORMATS.forEach((f: SnippetFormatDefinition) => { + expect(f.id).to.be.a('string').and.not.empty; + expect(f.folderName).to.be.a('string').and.not.empty; + expect(f.extension).to.be.a('string').and.not.empty; + expect(f.target).to.be.a('string').and.not.empty; + expect(f.client).to.be.a('string').and.not.empty; + expect(f.label).to.be.a('string').and.not.empty; + expect(f.popular).to.be.a('boolean'); + }); + }); + + it('includes cURL format', () => { + const curl = ALL_SNIPPET_FORMATS.find(f => f.id === 'shell_curl'); + expect(curl).to.exist; + expect(curl!.target).to.equal('shell'); + expect(curl!.client).to.equal('curl'); + }); + + it('includes Python Requests format', () => { + const python = ALL_SNIPPET_FORMATS.find(f => f.id === 'python_requests'); + expect(python).to.exist; + expect(python!.target).to.equal('python'); + }); + }); + + describe('DEFAULT_SELECTED_FORMAT_IDS', () => { + it('only contains IDs of popular formats', () => { + DEFAULT_SELECTED_FORMAT_IDS.forEach(id => { + const fmt = FORMAT_BY_ID.get(id); + expect(fmt).to.exist; + expect(fmt!.popular).to.be.true; + }); + }); + + it('is a subset of ALL_FORMAT_IDS', () => { + DEFAULT_SELECTED_FORMAT_IDS.forEach(id => { + expect(ALL_FORMAT_IDS.has(id)).to.be.true; + }); + }); + }); + + describe('FORMAT_CATEGORIES', () => { + it('contains at least 3 categories', () => { + expect(FORMAT_CATEGORIES.length).to.be.greaterThanOrEqual(3); + }); + + it('includes Shell and JavaScript', () => { + expect(FORMAT_CATEGORIES).to.include('Shell'); + expect(FORMAT_CATEGORIES).to.include('JavaScript'); + }); + }); + + describe('FORMATS_BY_CATEGORY', () => { + it('groups formats correctly', () => { + for (const cat of FORMAT_CATEGORIES) { + const formats = FORMATS_BY_CATEGORY[cat]; + expect(formats).to.be.an('array').and.not.empty; + formats.forEach(f => expect(f.category).to.equal(cat)); + } + }); + }); + + describe('resolveFormats', () => { + it('resolves known format IDs to definitions', () => { + const result = resolveFormats(new Set(['shell_curl', 'python_requests'])); + expect(result.length).to.equal(2); + expect(result.map(f => f.id)).to.include('shell_curl'); + }); + + it('silently skips unknown IDs', () => { + const result = resolveFormats(new Set(['shell_curl', 'nonexistent_format'])); + expect(result.length).to.equal(1); + }); + }); + +}); diff --git a/test/unit/util/export-filenames.spec.ts b/test/unit/util/export-filenames.spec.ts new file mode 100644 index 00000000..2c403fd7 --- /dev/null +++ b/test/unit/util/export-filenames.spec.ts @@ -0,0 +1,111 @@ +import { expect } from 'chai'; +import { + buildZipFileName, + buildZipArchiveName +} from '../../../src/util/export-filenames'; + +describe('export-filenames', () => { + + describe('buildZipFileName', () => { + + it('builds a standard filename with hostname', () => { + expect(buildZipFileName(1, 'GET', 200, 'sh', 'https://api.github.com/repos')) + .to.equal('001_GET_200_api.github.com.sh'); + }); + + it('builds a filename without URL', () => { + expect(buildZipFileName(3, 'POST', 201, 'py')) + .to.equal('003_POST_201.py'); + }); + + it('zero-pads the index to 3 digits', () => { + expect(buildZipFileName(1, 'GET', 200, 'sh')) + .to.equal('001_GET_200.sh'); + expect(buildZipFileName(42, 'POST', 201, 'py')) + .to.equal('042_POST_201.py'); + }); + + it('uppercases the method', () => { + expect(buildZipFileName(1, 'get', 200, 'sh')) + .to.equal('001_GET_200.sh'); + }); + + it('strips non-alpha characters from method', () => { + expect(buildZipFileName(1, 'M-SEARCH', 200, 'sh')) + .to.equal('001_MSEARCH_200.sh'); + }); + + it('uses "pending" for null status', () => { + expect(buildZipFileName(7, 'DELETE', null, 'js')) + .to.equal('007_DELETE_pending.js'); + }); + + it('handles zero status code', () => { + expect(buildZipFileName(1, 'GET', 0, 'sh')) + .to.equal('001_GET_0.sh'); + }); + + it('handles large index numbers beyond padding', () => { + expect(buildZipFileName(9999, 'PATCH', 204, 'ps1')) + .to.equal('9999_PATCH_204.ps1'); + }); + + it('uses "UNKNOWN" for empty method string', () => { + expect(buildZipFileName(1, '', 200, 'sh')) + .to.equal('001_UNKNOWN_200.sh'); + }); + + it('extracts hostname from URL and appends it', () => { + expect(buildZipFileName(5, 'POST', 201, 'py', 'https://httpbin.org/post?q=1')) + .to.equal('005_POST_201_httpbin.org.py'); + }); + + it('handles URL with port by dropping port', () => { + expect(buildZipFileName(1, 'GET', 200, 'sh', 'http://localhost:8080/api')) + .to.equal('001_GET_200_localhost.sh'); + }); + + it('handles IP address URLs', () => { + expect(buildZipFileName(1, 'GET', 200, 'sh', 'http://192.168.1.1/test')) + .to.equal('001_GET_200_192.168.1.1.sh'); + }); + + it('omits hostname when URL is undefined', () => { + expect(buildZipFileName(1, 'GET', 200, 'sh', undefined)) + .to.equal('001_GET_200.sh'); + }); + + it('omits hostname when URL is invalid', () => { + expect(buildZipFileName(1, 'GET', 200, 'sh', 'not-a-url')) + .to.equal('001_GET_200.sh'); + }); + }); + + describe('buildZipArchiveName', () => { + + it('starts with "HTTPToolkit_"', () => { + expect(buildZipArchiveName()).to.match(/^HTTPToolkit_/); + }); + + it('ends with ".zip"', () => { + expect(buildZipArchiveName()).to.match(/\.zip$/); + }); + + it('contains date and time', () => { + const name = buildZipArchiveName(); + // Should match: HTTPToolkit_YYYY-MM-DD_HH-MM.zip + expect(name).to.match(/^HTTPToolkit_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}\.zip$/); + }); + + it('includes exchange count when provided', () => { + const name = buildZipArchiveName(42); + expect(name).to.match(/^HTTPToolkit_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}_42-requests\.zip$/); + }); + + it('works without exchange count', () => { + const name = buildZipArchiveName(); + expect(name).to.not.include('requests'); + }); + }); + +}); From fbd57d4fe811161cd97ecf5e2357434f9c15789e Mon Sep 17 00:00:00 2001 From: stephanbuettig Date: Sat, 18 Apr 2026 23:21:56 +0000 Subject: [PATCH 2/5] fix: address all review feedback from PR #222 Changes addressing @pimterry's review: Architecture: - DI pattern for ZipExportController (testable without module stubs) - Shared sanitizer: simplifyHarEntryRequestForSnippetExport (single source of truth for snippet export sanitization, used by both single-export and ZIP-export paths) - Cooperative cancellation via MessageChannel + yieldToEventLoop() - callApi abort now rejects immediately (no 5-min hang if worker is stuck before first yield); exportAsZip translates AbortError back to cancelled response to preserve the public API contract Bug fixes: - Cancellation race: abortListener in callApi now calls finalize() + reject(AbortError) immediately, matching timeout handler behavior - Listener ordering: emitter.once registered before worker.postMessage to prevent latent race with synchronous worker responses - Type safety: replaced `undefined as any` with conditional spread in buildUltraSafeRequest Code quality: - All comments and debug logs translated to English (upstream PR ready) - formatBytes JSDoc corrected (SI-style labels, not IEC) - ZIP_DEBUG flag defaults to false Tests: - New: snippet-export-sanitization.spec.ts (hop-by-hop headers, empty query params, cookie clearing, postData.text preservation) - New: zip-export-service.spec.ts (stale-run invalidation, reset during in-flight run) - All 808 tests pass, 0 failures --- package.json | 2 +- src/components/view/http/http-export-card.tsx | 179 ++-- .../view/multi-selection-summary-pane.tsx | 324 +++++++ src/components/view/zip-export-dialog.tsx | 852 ++++++++++++++++++ src/icons.tsx | 10 +- src/model/ui/export.ts | Bin 6483 -> 4797 bytes src/model/ui/snippet-export-sanitization.ts | 90 ++ src/model/ui/ui-store.ts | 77 +- src/model/ui/zip-export-formats.ts | 200 ++++ src/model/ui/zip-export-service.ts | 372 ++++++++ src/model/ui/zip-manifest.ts | 78 ++ src/services/ui-worker-api.ts | 269 +++--- src/services/ui-worker.ts | 810 ++++++++++++----- src/util/export-filenames.ts | Bin 3825 -> 6572 bytes .../ui/snippet-export-sanitization.spec.ts | 46 + test/unit/model/ui/zip-export-service.spec.ts | 134 +++ test/unit/workers/zip-export.spec.ts | 341 +++++++ 17 files changed, 3348 insertions(+), 436 deletions(-) create mode 100755 src/components/view/multi-selection-summary-pane.tsx create mode 100755 src/components/view/zip-export-dialog.tsx create mode 100755 src/model/ui/snippet-export-sanitization.ts create mode 100755 src/model/ui/zip-export-formats.ts create mode 100755 src/model/ui/zip-export-service.ts create mode 100755 src/model/ui/zip-manifest.ts create mode 100755 test/unit/model/ui/snippet-export-sanitization.spec.ts create mode 100755 test/unit/model/ui/zip-export-service.spec.ts create mode 100755 test/unit/workers/zip-export.spec.ts diff --git a/package.json b/package.json index 0321228c..fe83b33e 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,7 @@ "date-fns": "^1.30.1", "dedent": "^0.7.0", "deserialize-error": "0.0.3", - "dompurify": "^3.3.3", + "dompurify": "^3.4.0", "fast-json-patch": "^3.1.1", "fast-xml-parser": "^5.5.7", "fflate": "^0.8.2", diff --git a/src/components/view/http/http-export-card.tsx b/src/components/view/http/http-export-card.tsx index 290bf509..f842718d 100644 --- a/src/components/view/http/http-export-card.tsx +++ b/src/components/view/http/http-export-card.tsx @@ -1,10 +1,9 @@ -import * as _ from 'lodash'; import React from "react"; -import { action, computed } from "mobx"; +import { action, computed, observable } from "mobx"; import { inject, observer } from "mobx-react"; import dedent from 'dedent'; -import { HttpExchangeView } from '../../../types'; +import { CollectedEvent, HttpExchangeView } from '../../../types'; import { styled } from '../../../styles'; import { Icon } from '../../../icons'; import { logError } from '../../../errors'; @@ -16,13 +15,11 @@ import { generateCodeSnippet, getCodeSnippetFormatKey, getCodeSnippetFormatName, - getCodeSnippetOptionFromKey, + getSafeCodeSnippetOptionFromKey, DEFAULT_SNIPPET_FORMAT_KEY, snippetExportOptions, SnippetOption } from '../../../model/ui/export'; -import { ZIP_ALL_FORMAT_KEY } from '../../../model/ui/snippet-formats'; -import { ZipDownloadPanel } from '../zip-download-panel'; import { ProHeaderPill, CardSalesPitch } from '../../account/pro-placeholders'; import { @@ -34,6 +31,7 @@ import { PillSelector, PillButton } from '../../common/pill'; import { CopyButtonPill } from '../../common/copy-button'; import { DocsLink } from '../../common/docs-link'; import { SelfSizedEditor } from '../../editor/base-editor'; +import { ZipExportDialog } from '../zip-export-dialog'; interface ExportCardProps extends CollapsibleCardProps { exchange: HttpExchangeView; @@ -138,116 +136,109 @@ const ExportHarPill = styled(observer((p: { margin-right: auto; `; -// Virtual SnippetOption used as the PillSelector value when ZIP is selected. -// This is never passed to httpsnippet — it's only used for dropdown rendering. -const ZIP_SNIPPET_OPTION: SnippetOption = { - target: ZIP_ALL_FORMAT_KEY as any, - client: '' as any, - name: 'ZIP (Selected Formats)', - description: 'Download selected code snippet formats in a single ZIP archive', - link: '' -}; - -// Build extended optGroups with ZIP at the top -const exportOptionsWithZip: _.Dictionary = { - 'Archive': [ZIP_SNIPPET_OPTION], - ...snippetExportOptions -}; - -const getExportFormatKey = (option: SnippetOption): string => { - if (option === ZIP_SNIPPET_OPTION) return ZIP_ALL_FORMAT_KEY; - return getCodeSnippetFormatKey(option); -}; - -const getExportFormatName = (option: SnippetOption): string => { - if (option === ZIP_SNIPPET_OPTION) return ZIP_SNIPPET_OPTION.name; - return getCodeSnippetFormatName(option); -}; - @inject('accountStore') @inject('uiStore') @observer export class HttpExportCard extends React.Component { + @observable + private zipDialogOpen = false; + + @action.bound + private openZipDialog() { this.zipDialogOpen = true; } + + @action.bound + private closeZipDialog() { this.zipDialogOpen = false; } + render() { const { exchange, accountStore } = this.props; const isPaidUser = accountStore!.user.isPaidUser(); - const isZipSelected = this.isZipSelected; - return -
- { isPaidUser - ? - : - } + return <> + +
+ { isPaidUser + ? <> + + {/* + * ZIP PillButton is active immediately (even when + * the card is collapsed). The click stops propagation + * so a header click underneath does not inadvertently + * toggle the card. + */} + { + e.stopPropagation(); + this.openZipDialog(); + }} + > + ZIP + + + : + } - - onChange={this.setSnippetOption} - value={this.currentDropdownValue} - optGroups={exportOptionsWithZip} - keyFormatter={getExportFormatKey} - nameFormatter={getExportFormatName} - /> - - - Export - -
- - { isPaidUser ? -
- { isZipSelected - ? - : + onChange={this.setSnippetOption} + value={this.snippetOption} + optGroups={snippetExportOptions} + keyFormatter={getCodeSnippetFormatKey} + nameFormatter={getCodeSnippetFormatName} + /> + + + Export + +
+ + { isPaidUser ? +
+ - } -
- : - -

- Instantly export requests as code, for languages and tools including cURL, wget, JS - (XHR, Node HTTP, Request, ...), Python (native or Requests), Ruby, Java (OkHttp - or Unirest), Go, PHP, Swift, HTTPie, and a whole lot more. -

-

- Want to save the exchange itself? Export one or all requests as HAR (the{' '} - HTTP Archive Format), to import - and examine elsewhere, share with your team, or store for future reference. -

-
- } -
; - } - - @computed - private get isZipSelected(): boolean { - return (this.props.uiStore!.exportSnippetFormat || '') === ZIP_ALL_FORMAT_KEY; - } - - @computed - private get currentDropdownValue(): SnippetOption { - if (this.isZipSelected) return ZIP_SNIPPET_OPTION; - return this.snippetOption; + + : + +

+ Instantly export requests as code, for languages and tools including cURL, wget, JS + (XHR, Node HTTP, Request, ...), Python (native or Requests), Ruby, Java (OkHttp + or Unirest), Go, PHP, Swift, HTTPie, and a whole lot more. +

+

+ Want to save the exchange itself? Export one or all requests as HAR (the{' '} + HTTP Archive Format), to import + and examine elsewhere, share with your team, or store for future reference. +

+
+ } + + {/* + * Dialog intentionally rendered OUTSIDE the CollapsibleCard. + * `CollapsibleCard.renderChildren()` discards all children + * after child 0 when the card is collapsed; placed there the + * dialog JSX would never appear in the DOM when the card is + * closed. The modal component uses a portal internally anyway, + * so its position in the React tree does not matter. + */} + {this.zipDialogOpen && } + ; } @computed private get snippetOption(): SnippetOption { let exportSnippetFormat = this.props.uiStore!.exportSnippetFormat || DEFAULT_SNIPPET_FORMAT_KEY; - // If ZIP is selected, fall back to default for the snippet option - if (exportSnippetFormat === ZIP_ALL_FORMAT_KEY) { - exportSnippetFormat = DEFAULT_SNIPPET_FORMAT_KEY; - } - // Guard: if the format key doesn't resolve (e.g. deleted/invalid key), - // fall back to the default cURL option - return getCodeSnippetOptionFromKey(exportSnippetFormat) - ?? getCodeSnippetOptionFromKey(DEFAULT_SNIPPET_FORMAT_KEY); + return getSafeCodeSnippetOptionFromKey(exportSnippetFormat); } @action.bound setSnippetOption(optionKey: string) { this.props.uiStore!.exportSnippetFormat = optionKey; } -}; \ No newline at end of file +}; + \ No newline at end of file diff --git a/src/components/view/multi-selection-summary-pane.tsx b/src/components/view/multi-selection-summary-pane.tsx new file mode 100755 index 00000000..7d697451 --- /dev/null +++ b/src/components/view/multi-selection-summary-pane.tsx @@ -0,0 +1,324 @@ +import * as React from 'react'; +import * as dateFns from 'date-fns'; +import { observable, action } from 'mobx'; +import { inject, observer } from 'mobx-react'; + +import { css, styled } from '../../styles'; +import { Ctrl, saveFile } from '../../util/ui'; +import { CollectedEvent } from '../../types'; +import { AccountStore } from '../../model/account/account-store'; +import { generateHar } from '../../model/http/har'; + +import { Icon } from '../../icons'; +import { Button } from '../common/inputs'; +import { GetProOverlay } from '../account/pro-placeholders'; + +import { getEventPreviewContent, getEventMarkerColor, isOpaqueConnection } from './event-rows/event-row'; +import { uppercaseFirst } from '../../util/text'; +import { ZipExportDialog } from './zip-export-dialog'; + +const SummaryContainer = styled.div` + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + height: 100%; + width: 100%; + box-sizing: border-box; + padding: 20px; + + background-color: ${p => p.theme.containerBackground}; +`; + +const PreviewStack = styled.div` + position: relative; + width: 80%; + height: 160px; +`; + +const PreviewRow = styled.div<{ + index: number, + markerColor: string, + dimRow: boolean +}>` + position: absolute; + top: calc(50% - ${p => p.index * 4}px); + transform: translateY(-50%) scaleX(${p => 1 - p.index * 0.03}); + height: 40px; + + left: 0; + right: 0; + + background-color: ${p => + p.dimRow + ? p.theme.mainBackground + Math.round(p.theme.lowlightTextOpacity * 255).toString(16) + : p.theme.mainBackground + }; + border-radius: 4px; + box-shadow: 0 2px 10px 0 rgba(0,0,0,${p => p.theme.boxShadowAlpha}); + + opacity: ${p => 1 - p.index * 0.12}; + z-index: ${p => 9 - p.index}; + + border-left: 5px solid ${p => p.markerColor}; + + display: flex; + align-items: center; + gap: 8px; + padding: 2px 10px 0; + box-sizing: border-box; + + font-size: ${p => p.theme.textSize}; + color: ${p => p.dimRow ? p.theme.mainColor : p.theme.containerWatermark}; + + overflow: hidden; + white-space: nowrap; + + transition: top 0.15s ease-out, + transform 0.15s ease-out, + opacity 0.15s ease-out; +`; + +const SelectionLabel = styled.div` + position: absolute; + top: calc(50% - 24px); + left: 0; + right: 0; + transform: translateY(-50%); + z-index: 10; + + text-align: center; + color: ${p => p.theme.mainColor}; + font-size: ${p => p.theme.loudHeadingSize}; + font-weight: bold; + letter-spacing: -1px; + + background: radial-gradient( + ellipse at center, + ${p => p.theme.containerBackground}c0 30%, + transparent 70% + ); + padding: 50px 0; + + pointer-events: none; +`; + +const ActionsContainer = styled.div` + display: flex; + flex-direction: column; + align-items: stretch; + gap: 10px; + margin-top: 30px; + width: 60%; + max-width: 360px; +`; + +const ActionButton = styled(Button)` + display: flex; + align-items: center; + justify-content: flex-start; + gap: 12px; + padding: 10px 16px; + font-size: ${p => p.theme.textSize}; + + > .fa-fw { + width: 1.25em; + flex-shrink: 0; + } +`; + +// React wrapper: consumes `pinned` explicitly before passing props to +// Icon/SVG. This is more robust than styled-components +// `withConfig({ shouldForwardProp })`, which does not reliably filter on +// non-DOM components and triggers the dev-console warning +// "Received 'false' for a non-boolean attribute 'pinned'". +interface PinIconBaseProps { + pinned: boolean; + className?: string; + fixedWidth?: boolean; +} +const PinIconBase = (props: PinIconBaseProps) => ( + +); + +const PinIcon = styled(PinIconBase)` + transition: transform 0.1s; + + ${p => !p.pinned && css` + transform: rotate(45deg); + `} +`; + +const ProDivider = styled.hr` + width: 100%; + margin: 36px 0; + border: none; + border: solid 1px ${p => p.theme.mainColor}; +`; + +const ProActionsContainer = styled.div` + display: flex; + flex-direction: column; + align-items: stretch; + gap: 10px; + width: 100%; +`; + +const ProActionsOverlay = styled(GetProOverlay)` + min-height: 0; + + > button { + top: 50%; + } +`; + +const PREVIEW_COUNT = 10; + +interface MultiSelectionSummaryPaneProps { + accountStore?: AccountStore; + selectedEvents: ReadonlyArray; + onPin: () => void; + onDelete: () => void; + onBuildRule: () => void; +} + +@inject('accountStore') +@observer +export class MultiSelectionSummaryPane extends React.Component { + + @observable + private zipDialogEvents: ReadonlyArray | null = null; + + @action.bound + private openZipDialog(events: ReadonlyArray) { + this.zipDialogEvents = events; + } + + @action.bound + private closeZipDialog() { + this.zipDialogEvents = null; + } + + render() { + const { selectedEvents } = this.props; + const count = selectedEvents.length; + const isPaidUser = this.props.accountStore!.user.isPaidUser(); + + const exportableEvents = selectedEvents.filter(e => + e.isHttp() && !e.isWebSocket() + ); + const httpCount = exportableEvents.length; + + const allHttp = count > 0 && selectedEvents.every(e => e.isHttp()); + const allPinned = selectedEvents.every(e => e.pinned); + const label = allHttp ? 'request' : 'event'; + + // selectedEvents is in selection order (most recent last). + // Reverse so the most recent is the front card (index 0). + const previewEvents = selectedEvents.slice(-PREVIEW_COUNT).reverse(); + + const proButtons = <> + + + Create {httpCount} Matching Rule{httpCount !== 1 ? 's' : ''} + + { + const harContent = JSON.stringify( + await generateHar(selectedEvents, { bodySizeLimit: Infinity }) + ); + const filename = `HTTPToolkit_${ + dateFns.format(Date.now(), 'YYYY-MM-DD_HH-mm') + }.har`; + saveFile(filename, 'application/har+json;charset=utf-8', harContent); + }} + > + + Export as HAR + + this.openZipDialog(exportableEvents)} + > + + Export as ZIP + + ; + + return + + {previewEvents.map((event, index) => { + return + {getEventPreviewContent(event)} + ; + })} + + {count} {label}{count !== 1 ? 's' : ''} selected + + + + + + + Toggle Pinning + + + + Delete {count} {uppercaseFirst(label)}{count !== 1 ? 's' : ''} + + + { isPaidUser + ? proButtons + : <> + + + + {proButtons} + + + + } + + + {this.zipDialogEvents && } + ; + } +} + \ No newline at end of file diff --git a/src/components/view/zip-export-dialog.tsx b/src/components/view/zip-export-dialog.tsx new file mode 100755 index 00000000..cfb00181 --- /dev/null +++ b/src/components/view/zip-export-dialog.tsx @@ -0,0 +1,852 @@ +/** + * Modal dialog for selecting ZIP export formats. + * + * Reads formats dynamically from `zip-export-formats.ts` (single source of + * truth). Keeps the selection in `UiStore` (persisted) so it remains stable + * across sessions. + */ +import * as React from 'react'; +import { action, computed, observable } from 'mobx'; +import { inject, observer } from 'mobx-react'; + +import { styled, css } from '../../styles'; +import { UiStore } from '../../model/ui/ui-store'; +import { CollectedEvent } from '../../types'; + +import { + ZIP_EXPORT_CATEGORIES, + ZIP_EXPORT_FORMATS_BY_CATEGORY, + DEFAULT_SELECTED_FORMAT_IDS, + ALL_FORMAT_IDS, + sanitizeFormatIds +} from '../../model/ui/zip-export-formats'; +import { ZipExportController } from '../../model/ui/zip-export-service'; +import { prewarmZipExport } from '../../services/ui-worker-api'; + +import { Button, ButtonLink, SecondaryButton } from '../common/inputs'; +import { Icon } from '../../icons'; + +const Overlay = styled.div` + position: fixed; + inset: 0; + + /* Subtle dark scrim + blur. No full-color gradient that completely + * covers the rest of the app — just hinted so the dialog frame stands + * out clearly while the app remains visible (cf. VS Code / GitHub + * Command Palette). */ + background: rgba(8, 10, 16, 0.62); + backdrop-filter: blur(3px); + -webkit-backdrop-filter: blur(3px); + + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + + animation: zipOverlayFadeIn 0.14s ease-out both; + + @keyframes zipOverlayFadeIn { + from { opacity: 0; } + to { opacity: 1; } + } +`; + +const Modal = styled.div` + position: relative; + width: min(760px, 92vw); + max-height: 85vh; + display: flex; + flex-direction: column; + overflow: hidden; + + background: ${p => p.theme.mainBackground}; + color: ${p => p.theme.mainColor}; + + border-radius: 8px; + box-shadow: 0 0 0 1px ${p => p.theme.containerBorder} inset, + 0 20px 60px rgba(0, 0, 0, 0.55); + + font-family: ${p => p.theme.fontFamily}; + line-height: 1.5; + + animation: zipModalPop 0.18s cubic-bezier(0.2, 0.9, 0.35, 1) both; + + @keyframes zipModalPop { + from { transform: translateY(6px) scale(0.985); opacity: 0; } + to { transform: translateY(0) scale(1); opacity: 1; } + } +`; + +/** + * YouTube-inspired progress line. Sits between TopBar and + * StatusBanner/ModalBody and shows export progress as a slim 2px line. + * Fixed reservation slot via min-height so the layout does not jump + * when the line toggles between visible and hidden. + */ +const TopProgressBar = styled.div<{ percent: number, indeterminate: boolean }>` + flex-shrink: 0; + height: 2px; + width: 100%; + background: transparent; + pointer-events: none; + overflow: hidden; + opacity: ${p => p.percent > 0 || p.indeterminate ? 1 : 0}; + transition: opacity 0.2s ease; + + &::after { + content: ''; + display: block; + height: 100%; + background: ${p => p.theme.primaryInputBackground}; + box-shadow: 0 0 8px ${p => p.theme.primaryInputBackground}; + + ${p => p.indeterminate ? css` + width: 40%; + animation: zipIndeterminate 1.1s ease-in-out infinite; + ` : css` + width: ${Math.min(100, Math.max(0, p.percent))}%; + transition: width 0.18s ease-out; + `} + } + + @keyframes zipIndeterminate { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(350%); } + } +`; + +const ModalHeader = styled.header` + position: relative; + display: flex; + align-items: center; + gap: 14px; + padding: 18px 60px 18px 28px; + flex-shrink: 0; + + background: ${p => p.theme.mainLowlightBackground}; + border-bottom: 1px solid ${p => p.theme.containerBorder}; + border-radius: 8px 8px 0 0; + + font-family: ${p => p.theme.titleTextFamily}; + font-size: ${p => p.theme.headingSize}; + font-weight: bold; + + > svg:first-child { + color: ${p => p.theme.popColor}; + font-size: ${p => p.theme.subHeadingSize}; + } +`; + +const CloseIconButton = styled.button.attrs(() => ({ + type: 'button' as const, + 'aria-label': 'Close dialog' +}))` + position: absolute; + top: 50%; + right: 18px; + transform: translateY(-50%); + + width: 32px; + height: 32px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + + background: transparent; + border: none; + border-radius: 4px; + cursor: pointer; + + color: ${p => p.theme.mainColor}; + font-size: ${p => p.theme.headingSize}; + line-height: 1; + opacity: 0.65; + + &:hover:not([disabled]) { + opacity: 1; + background: ${p => p.theme.containerBackground}; + } + + &:focus { + outline: 2px solid ${p => p.theme.primaryInputBackground}; + outline-offset: 1px; + } + + &[disabled] { + opacity: 0.3; + cursor: default; + } +`; + +const TopBar = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + + padding: 10px 28px; + flex-shrink: 0; + + background: ${p => p.theme.containerBackground}; + border-bottom: 1px solid ${p => p.theme.containerBorder}; + + font-size: ${p => p.theme.textSize}; +`; + +const TopBarInfo = styled.span` + color: ${p => p.theme.mainColor}; +`; + +const TopBarActions = styled.div` + display: flex; + gap: 6px; + flex-wrap: wrap; +`; + +const ModalBody = styled.div` + padding: 20px 28px; + overflow-y: auto; + flex: 1 1 auto; +`; + +const ModalFooter = styled.footer` + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 16px 28px; + flex-shrink: 0; + + background: ${p => p.theme.mainLowlightBackground}; + border-top: 1px solid ${p => p.theme.containerBorder}; + border-radius: 0 0 8px 8px; +`; + +const CategoryBlock = styled.section` + margin-bottom: 20px; + + &:last-child { + margin-bottom: 0; + } +`; + +const CategoryHeading = styled.h4` + margin: 0 0 10px 0; + + font-family: ${p => p.theme.titleTextFamily}; + font-size: ${p => p.theme.subHeadingSize}; + font-weight: bold; + letter-spacing: 0.02em; + text-transform: uppercase; + color: ${p => p.theme.mainColor}; + + padding-bottom: 6px; + border-bottom: 1px solid ${p => p.theme.containerBorder}; +`; + +const FormatGrid = styled.div` + display: grid; + /* Slightly wider columns so long client names like + * "Invoke-WebRequest" or "HttpClient" do not wrap to two lines. */ + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 4px 16px; +`; + +const FormatLabel = styled.label` + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + padding: 6px 8px; + border-radius: 4px; + min-width: 0; /* grid children otherwise never shrink below content */ + + font-size: ${p => p.theme.textSize}; + color: ${p => p.theme.mainColor}; + + &:hover { + background: ${p => p.theme.containerBackground}; + } + + input[type='checkbox'] { + margin: 0; + accent-color: ${p => p.theme.primaryInputBackground}; + cursor: pointer; + flex-shrink: 0; + } +`; + +const FormatLabelText = styled.span` + flex: 1 1 auto; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +const TinyButton = styled(SecondaryButton).withConfig({ + shouldForwardProp: (prop: any) => prop !== 'active' +})<{ active?: boolean }>` + padding: 3px 10px; + font-size: ${p => p.theme.textInputFontSize}; + border-width: 1px; + border-radius: 3px; + + ${p => p.active && css` + &:not([disabled]) { + background-color: ${p.theme.primaryInputBackground}; + border-color: ${p.theme.primaryInputBackground}; + color: ${p.theme.primaryInputColor}; + } + &:not([disabled]), &:not([disabled]):visited { + color: ${p.theme.primaryInputColor}; + } + `} +`; + +const primaryButtonShellStyles = css` + padding: 10px 22px; + font-size: ${p => p.theme.subHeadingSize}; + border-radius: 4px; + + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + min-height: 42px; + /* Fixed minimum width for all primary-button states. Prevents + * the button from growing/shrinking as the label changes from + * "Download ZIP" (init) through "0 %" ... "100 %" (running) to + * "Save archive (X MB)" (done). Without min-width the flex container + * jerks the Cancel button to the left during the running phase. + * 200px comfortably covers the widest label case + * ("Save archive (999 KB)"). */ + min-width: 200px; + + transition: background-color 0.15s ease, box-shadow 0.15s ease, filter 0.15s ease; +`; + +const PrimaryButton = styled(Button)` + ${primaryButtonShellStyles} +`; + +/** + * Primary-styled anchor — for the "Save archive" button in the done state, + * which must be a real `` (programmatic downloads only work in + * Chrome with an active gesture trust). + */ +const PrimaryDownloadLink = styled(ButtonLink)` + ${primaryButtonShellStyles} + + &:hover:not([disabled]) { + filter: brightness(1.08); + } +`; + +/** + * Small round spinner for the running state of the primary button. + * Intentionally lightweight and library-free (no extra bundle). + */ +const ButtonSpinner = styled.span` + display: inline-block; + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid ${p => p.theme.primaryInputColor}; + border-top-color: transparent; + animation: zipSpin 0.75s linear infinite; + + @keyframes zipSpin { + to { transform: rotate(360deg); } + } +`; + +const ButtonProgressLabel = styled.span` + font-variant-numeric: tabular-nums; + font-weight: bold; + letter-spacing: 0.01em; + /* Width cap so "0 %" and "100 %" render at the same width + * (tabular-nums unifies digits but overall label length still + * changes with 1 vs 2 vs 3 digits). Combined with min-width on + * the shell, this prevents any layout jump. */ + min-width: 5ch; + text-align: center; +`; + +const ButtonFileSize = styled.span` + font-variant-numeric: tabular-nums; + opacity: 0.85; + font-weight: normal; + font-size: ${p => p.theme.textSize}; +`; + +const FooterButton = styled(SecondaryButton)` + padding: 10px 20px; + font-size: ${p => p.theme.subHeadingSize}; + border-width: 1px; + border-radius: 4px; +`; + +const ProgressRow = styled.div` + margin-top: 20px; + padding: 12px 14px; + background: ${p => p.theme.containerBackground}; + border-radius: 4px; + border: 1px solid ${p => p.theme.containerBorder}; +`; + +const ProgressBarOuter = styled.div` + width: 100%; + /* Modern, very slim progress bar. */ + height: 4px; + background: ${p => p.theme.mainLowlightBackground}; + border-radius: 2px; + overflow: hidden; +`; + +const ProgressBarInner = styled.div<{ percent: number }>` + height: 100%; + width: ${p => Math.min(100, Math.max(0, p.percent))}%; + background: ${p => p.theme.primaryInputBackground}; + transition: width 0.18s ease-out; +`; + +const StatusLine = styled.div` + margin-top: 8px; + display: flex; + justify-content: space-between; + gap: 12px; + + font-family: ${p => p.theme.monoFontFamily}; + font-size: ${p => p.theme.textInputFontSize}; + color: ${p => p.theme.mainLowlightColor}; + + > span:last-child { + font-variant-numeric: tabular-nums; + } +`; + +/** + * Sticky status banner between TopBar and ModalBody. Stays visible + * even when the user has scrolled the format list (common with many + * formats). Three variants: + * - success (done) + * - warning (error) + * - neutral (cancelled) + * Intentionally without a redundant download button or reload hint — + * that is the footer button's job (bottom-right). + */ +const StatusBanner = styled.div<{ tone: 'success' | 'warning' | 'neutral' }>` + flex-shrink: 0; + display: flex; + align-items: center; + gap: 10px; + + padding: 10px 28px; + font-size: ${p => p.theme.textSize}; + color: ${p => p.theme.mainColor}; + + background: ${p => p.tone === 'warning' + ? p.theme.warningBackground + : p.theme.containerBackground}; + border-top: 1px solid ${p => p.theme.containerBorder}; + border-bottom: 1px solid ${p => p.theme.containerBorder}; + border-left: 3px solid ${p => { + if (p.tone === 'success') return p.theme.primaryInputBackground; + if (p.tone === 'warning') return p.theme.warningColor; + return p.theme.containerBorder; + }}; + + > svg:first-child { + flex-shrink: 0; + color: ${p => { + if (p.tone === 'success') return p.theme.primaryInputBackground; + if (p.tone === 'warning') return p.theme.warningColor; + return p.theme.mainLowlightColor; + }}; + } +`; + +const BannerText = styled.span` + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + font-variant-numeric: tabular-nums; +`; + +const SmallMuted = styled.span` + font-size: ${p => p.theme.textInputFontSize}; + color: ${p => p.theme.mainLowlightColor}; +`; + +const PopularBadge = styled.span` + font-size: ${p => p.theme.smallPrintSize}; + padding: 1px 6px; + border-radius: 3px; + background: ${p => p.theme.primaryInputBackground}; + color: ${p => p.theme.primaryInputColor}; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 0.04em; +`; + +interface ZipExportDialogProps { + uiStore?: UiStore; + events: ReadonlyArray; + onClose: () => void; + titleSuffix?: string; +} + +/** + * Formats bytes as a short, human-readable size using binary divisors + * (1024) with common SI-style labels (KB/MB/GB) — the convention most + * users expect in a download UX. + */ +function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + let value = bytes; + let idx = 0; + while (value >= 1024 && idx < units.length - 1) { + value /= 1024; + idx++; + } + const rounded = value >= 100 || idx === 0 + ? Math.round(value).toString() + : value.toFixed(value >= 10 ? 1 : 2); + return `${rounded} ${units[idx]}`; +} + +/** Stable DOM IDs for aria-labelledby / aria-describedby. */ +let ZIP_DIALOG_SEQ = 0; + +@inject('uiStore') +@observer +export class ZipExportDialog extends React.Component { + private readonly controller = new ZipExportController(); + private readonly titleId = `zip-export-dialog-title-${++ZIP_DIALOG_SEQ}`; + private readonly descId = `zip-export-dialog-desc-${ZIP_DIALOG_SEQ}`; + private readonly previouslyFocused: HTMLElement | null = ( + typeof document !== 'undefined' ? document.activeElement as HTMLElement | null : null + ); + private submitButtonRef = React.createRef(); + + @observable + private selected: Set = (() => { + const persisted = this.props.uiStore!.zipExportSelectedFormatIds; + if (persisted && persisted.length) { + const cleaned = sanitizeFormatIds(persisted); + if (cleaned.length) return new Set(cleaned); + } + return new Set(DEFAULT_SELECTED_FORMAT_IDS); + })(); + + @computed + private get selectedCount(): number { + return this.selected.size; + } + + private setsEqual(a: Iterable, b: Set): boolean { + let count = 0; + for (const id of a) { + if (!b.has(id)) return false; + count++; + } + return count === b.size; + } + + @computed + private get isAllSelected(): boolean { + return this.setsEqual(ALL_FORMAT_IDS, this.selected); + } + + @computed + private get isNoneSelected(): boolean { + return this.selected.size === 0; + } + + @computed + private get isPopularSelected(): boolean { + return this.setsEqual(DEFAULT_SELECTED_FORMAT_IDS, this.selected); + } + + componentDidMount() { + document.addEventListener('keydown', this.onKeyDown); + // Fire-and-forget prewarm: initializes HTTPSnippet + fflate in + // the worker while the user is still selecting formats. The first + // real export click then starts on an already JIT-compiled hot + // path instead of incurring module-init costs. + void prewarmZipExport(); + // Initial focus only after the next paint so React has actually + // rendered the button. setTimeout(0) previously caused a race + // with the first user click (first click didn't register because + // gesture trust was already consumed). + requestAnimationFrame(() => { + requestAnimationFrame(() => this.submitButtonRef.current?.focus()); + }); + } + + componentWillUnmount() { + document.removeEventListener('keydown', this.onKeyDown); + // Clean up blob URL + running run to prevent memory leaks. + try { this.controller.dispose(); } catch { /* noop */ } + // Return focus to the element that opened the dialog. + try { this.previouslyFocused?.focus?.(); } catch { /* noop */ } + } + + private onKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return; + const state = this.controller.state; + if (state.kind === 'running' || state.kind === 'preparing') { + this.controller.cancel(); + } else { + this.props.onClose(); + } + }; + + @action.bound + private toggle(id: string) { + if (this.selected.has(id)) this.selected.delete(id); + else this.selected.add(id); + } + + @action.bound + private selectAll() { + this.selected = new Set(ALL_FORMAT_IDS); + } + @action.bound + private selectNone() { + this.selected = new Set(); + } + @action.bound + private selectPopular() { + this.selected = new Set(DEFAULT_SELECTED_FORMAT_IDS); + } + + @action.bound + private async startExport() { + this.props.uiStore!.setZipExportSelectedFormatIds(Array.from(this.selected)); + await this.controller.run({ + events: this.props.events, + formatIds: this.selected + }); + // No auto-close: the dialog stays open after done so the visible + // fallback download link remains clickable in case Chrome rejected + // the programmatic download. + } + + @action.bound + private onCancelRunning() { + this.controller.cancel(); + } + + @action.bound + private onRetry() { + this.controller.reset(); + } + + /** + * Sorts formats per category: popular first (in overlay definition + * order), then alphabetically by label. The result is computed once + * per render — the source data is static, so no memoization needed. + */ + private sortCategoryFormats( + formats: ReadonlyArray<{ id: string; label: string; popular: boolean }> + ): ReadonlyArray<{ id: string; label: string; popular: boolean }> { + return [...formats].sort((a, b) => { + if (a.popular !== b.popular) return a.popular ? -1 : 1; + return a.label.localeCompare(b.label); + }); + } + + render() { + const { events, titleSuffix, onClose } = this.props; + const state = this.controller.state; + const running = state.kind === 'running' || state.kind === 'preparing'; + const percent = state.kind === 'running' ? state.percent : 0; + const indeterminate = state.kind === 'preparing'; + + return + e.stopPropagation()}> + + + Export as ZIP{titleSuffix ? ` (${titleSuffix})` : ''} + + + + + + + {events.length} request{events.length !== 1 ? 's' : ''} + {' · '} + {this.selectedCount} / {ALL_FORMAT_IDS.size} formats + + + Popular + Select all + Select none + + + + {/* + * Slim progress line: sits between TopBar and status + * banner, shows current export progress or pulses during + * `preparing`. Hidden when no run is active. + */} + + + {/* + * Sticky status banner: always directly below the progress + * line, never in the scrollable body. The user sees "Done" + * even if they scrolled the format list before. + */} + {state.kind === 'done' && + + + {state.autoDownloadAttempted ? 'Saved' : 'Ready'} + {' — '} + {state.snippetSuccessCount} snippet{state.snippetSuccessCount !== 1 ? 's' : ''} generated + {state.snippetErrorCount > 0 && `, ${state.snippetErrorCount} failed`} + {' · '} + {formatBytes(state.downloadBytes)} + + } + {state.kind === 'error' && + + {state.message} + } + {state.kind === 'cancelled' && + + Export cancelled. + } + + + {ZIP_EXPORT_CATEGORIES.map(cat => ( + + {cat} + + {this.sortCategoryFormats( + ZIP_EXPORT_FORMATS_BY_CATEGORY[cat] + ).map(fmt => ( + + this.toggle(fmt.id)} + /> + + {fmt.label} + + {fmt.popular && popular} + + ))} + + + ))} + + {running && + + + + + + {state.kind === 'running' + ? `${state.stage} · ${state.currentRequest ?? 0} / ${state.totalRequests ?? 0}` + : 'preparing…'} + + + {state.kind === 'running' ? `${Math.round(state.percent)} %` : ''} + + + } + + + + + HAR + manifest.json are included in every archive. + +
+ {running + ? <> + Cancel + + + + : state.kind === 'done' + ? <> + Close + + + {state.autoDownloadAttempted ? 'Saved' : 'Save archive'} + + ({formatBytes(state.downloadBytes)}) + + + + : (state.kind === 'error' || state.kind === 'cancelled') + ? { this.onRetry(); this.startExport(); }} + disabled={this.selectedCount === 0 || events.length === 0} + > + + Retry + + : ({ function iconHtml(iconLookup: IconLookup, options?: IconParams): string { return icon(iconLookup, options).html.join(''); -} \ No newline at end of file +} diff --git a/src/model/ui/export.ts b/src/model/ui/export.ts index 69907e95b1e580e5cf0ee40ae35f6424f8882750..ea72b213581f3a92674a1160b7e472dff93e424d 100644 GIT binary patch delta 780 zcmb_ay-p)B5Wa|?Ad3z-A(|Ts1PdZ7QC`GRoIoH7P6R}oRWeH~Hg;}2dt!;A@C`q$Bl=jyNSwBc6*u_`U31RHcDii?MAe% zn6g9so1)>0kS1kq*lkUQDEj0dp9gD=eiIik{5wX;2xh_N)!XcJPh&2J8 zOT$u~+6yD8XqZ4NN)jX!vQ;v`ors}hi!@1>$-sArZAVtfHoLlwNtnvet2S%Uu#^dc z(J8eMbPP->cOa4f(1279+I+Xwq3^ZErfJb${df=7;6&3I5|~az2(pceT{}Q&f~F?C zF!Vp`7!0f#C8(v9Vf%mQD1a8=rlncZ%GIV$A=4|1sS935Lm97Qp7Oa`ZBWl{3tOGIZPE8`V8GOxOg= z@Y@<+Zo)zFykpm3AF~2kFT@?tad}t(C@oVT@#fES3)Nt;F06l}`uu$5 JNPPSH^9n=X_2d8m delta 2527 zcmai0%Z?jG6jcb5pvj6I3l?{SY)789G%MoKAVonT5eOw=P!Ue>GEQyA8Lc@?NtujvAYa&~%xJ+=S}8o|*5R7!4{NieTst3<&D*1| zpCRg}mSe0+g}{!qB+;JOIqN)~(27T8NtJrdT^N2-mpVmYZDKiiwk}Yqa68#Nz4L2} zppPX53Scsf`huk;2U7Ip;fpScs%vL!r*W%XljGX4XA^vv-0}AfdtC4caZLC_%#|`h zBJ~M%n8!D%H8@!)_oBbFd3)!VInG}cY+?yGamgq#q7W;vB)Ij^FfH{ZqZ#E;3TNo&>>Ev0;ADj zcM)BnTi{Y`yBk`TB1fL?d#=#0PE}d1+xvvx^rb2#0xpXTtkX+vkgLBrC1c}D5S}tI zT3!#(1#3&W2h6huFBj{;CpdQ^|B%iQRx%BtJdOJ@IL|o^E${(Lx~i@BdQtKup$kiy z-NuJvBGeg3rGiz)&`!i#QhQ33*(O&_un=3<+Q32Oxx8fGQR+A}&d?7rJjmbtevjJe z@HE7q?~tDKIg?zM(BXL8Jx}QTlbbN2BN5U==s7Kg4SzE&q|ldOv|rwmdkR~@JhjdN z58*T5`U&`0hGJV-AAjf{>J@IZ!vx3_ssUeG73_e!5ZxGGbEVs5GMj3Zi{iIf)X7wRU8VN5r_1k zQ5kEH3NWK?qwlF)Trk&fWXg*WY7Z}2>d+R=A^~Qz&n>`I2k?ll&pmsS~~>WJ~OavY4Tg^W`n0IF8xgA;s|StD6OkFfz76TSQ&Sq5Xn z+R}`>DB0lyk^0<_eYZ*EYp~&QsUQNDr#+6&82l;IP{* z85d_Z^r5jfU{ncx3EQmMiP24I(O|KKJtCnJ?9{efs_^X_U*2)$Zv6`A)(A-ALI4ED zDV%V%;7q1&=sDtjm}OV|;|im>o{H1tjmOP)Ds~rFDQ7xN;Qyp{VoLDRE_Es%7R{et zYm*)3u=y}x;xxOqEDqJ(+k&~a4GNIP8{aZ4MCZ9qn=;IHR>59|L(xa;_;=?Rvi!Gq z^tKXQ+rU-^#;+zfGE1#}4Pnjcn{UM(F*i_=C~0ddo?#l@4~_meOYhLr!Ga4e>%568 zGHc}LjVx`^V9a=Rc6@bq{nx$b-OrAZ;j6-~;&^pCgv2AKkq$FhHx_FAI{KNdP*1hc R%f>dc38C-n=kI@T>u-moK`Q_N diff --git a/src/model/ui/snippet-export-sanitization.ts b/src/model/ui/snippet-export-sanitization.ts new file mode 100755 index 00000000..ebd62462 --- /dev/null +++ b/src/model/ui/snippet-export-sanitization.ts @@ -0,0 +1,90 @@ +import type * as HarFormat from 'har-format'; + +/** + * Shared snippet-export sanitizing rules. + * + * This module is safe to import from both the browser UI and the web worker. + */ +type SnippetHeaderLike = Pick; + +const HOP_BY_HOP_HEADERS = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', + 'content-length', + 'content-encoding' +]); + +function getConnectionListedHeaderNames( + headers: readonly SnippetHeaderLike[] +): ReadonlySet { + const connectionListedHeaders = new Set(); + + for (const header of headers) { + if ((header?.name ?? '').toLowerCase() !== 'connection') continue; + + for (const token of String(header?.value ?? '').split(',')) { + const normalized = token.trim().toLowerCase(); + if (normalized) connectionListedHeaders.add(normalized); + } + } + + return connectionListedHeaders; +} + +export function isDroppableSnippetHeader( + name: string | undefined | null, + connectionListedHeaders: ReadonlySet = new Set() +): boolean { + if (!name) return true; + if (name.startsWith(':')) return true; + + const lower = name.toLowerCase(); + return HOP_BY_HOP_HEADERS.has(lower) || connectionListedHeaders.has(lower); +} + +export function filterHeadersForSnippetExport( + headers: readonly T[] | undefined +): T[] { + const inputHeaders = headers || []; + const connectionListedHeaders = getConnectionListedHeaderNames(inputHeaders); + + return inputHeaders.filter((header) => + !isDroppableSnippetHeader(header?.name, connectionListedHeaders) + ); +} + +/** + * Sanitize a HAR request for snippet export without changing its effective + * payload. In particular, keep raw `postData.text` even when `params` are + * present, so ZIP export stays aligned with single-request export. + */ +export function simplifyHarEntryRequestForSnippetExport( + harRequest: HarFormat.Request +): HarFormat.Request { + const headers = filterHeadersForSnippetExport(harRequest.headers); + const queryString = (harRequest.queryString || []).filter( + (q) => (q?.name ?? '') !== '' || (q?.value ?? '') !== '' + ); + + let postData = harRequest.postData; + if (postData && 'text' in postData) { + postData = { + ...postData, + text: postData.text ?? '' + } as HarFormat.PostData; + } + + return { + ...harRequest, + headers, + queryString, + cookies: [], + postData + }; +} diff --git a/src/model/ui/ui-store.ts b/src/model/ui/ui-store.ts index d267d9bd..3429cc40 100644 --- a/src/model/ui/ui-store.ts +++ b/src/model/ui/ui-store.ts @@ -8,7 +8,6 @@ import { persist, hydrate } from '../../util/mobx-persist/persist'; import { unreachableCheck, UnreachableCheck } from '../../util/error'; import { AccountStore } from '../account/account-store'; -import { DEFAULT_SELECTED_FORMAT_IDS } from './snippet-formats'; import { emptyFilterSet, FilterSet } from '../filters/search-filters'; import { DesktopApi } from '../../services/desktop-api'; import { @@ -275,7 +274,10 @@ export class UiStore { viewScrollPosition: number | 'end' = 'end'; @observable - selectedEventId: string | undefined; + selectedEventIds: Set = observable.set(); + + @observable + activeEventId: string | undefined; @computed get viewCardProps() { @@ -463,22 +465,34 @@ export class UiStore { exportSnippetFormat: string | undefined; /** - * Persisted list of snippet format IDs selected for ZIP export. - * Shared between the Export card (single exchange) and the batch toolbar - * (multi-select), so the user's choice is consistent everywhere. - * Initialized with popular defaults; updated via setZipFormatIds(). + * Persisted ZIP export format selection. We store a versioned JSON + * structure so that future extensions (e.g. per-format options) remain + * backward-compatible. The reader filters out unknown format IDs so + * that updating to a new HTTPSnippet version does not corrupt the store. */ - @persist('list') @observable - _zipFormatIds: string[] = [...DEFAULT_SELECTED_FORMAT_IDS]; + @persist @observable + private _zipExportSelection: string | undefined; @computed - get zipFormatIds(): ReadonlySet { - return new Set(this._zipFormatIds); + get zipExportSelectedFormatIds(): string[] | undefined { + if (!this._zipExportSelection) return undefined; + try { + const parsed = JSON.parse(this._zipExportSelection); + if (parsed && parsed.version === 1 && Array.isArray(parsed.ids)) { + return parsed.ids.filter((x: unknown) => typeof x === 'string'); + } + } catch { + /* corrupt value, ignore */ + } + return undefined; } @action.bound - setZipFormatIds(ids: ReadonlySet | string[]) { - this._zipFormatIds = Array.isArray(ids) ? [...ids] : [...ids]; + setZipExportSelectedFormatIds(ids: string[]) { + this._zipExportSelection = JSON.stringify({ + version: 1, + ids: ids.filter(x => typeof x === 'string') + }); } // Actions for persisting view state when switching tabs @@ -487,9 +501,43 @@ export class UiStore { this.viewScrollPosition = position; } + // The various ways to (de)select one or more events: + + @action.bound + selectSingleEvent(eventId: string | undefined) { + this.selectedEventIds.clear(); + if (eventId) { + this.selectedEventIds.add(eventId); + this.activeEventId = eventId; + } else { + this.activeEventId = undefined; + } + } + + @action.bound + toggleEventSelection(eventId: string) { + if (this.selectedEventIds.has(eventId)) { + this.selectedEventIds.delete(eventId); + } else { + this.selectedEventIds.add(eventId); + } + this.activeEventId = eventId; + } + + @action.bound + setSelectedEvents(eventIds: string[]) { + this.selectedEventIds.clear(); + for (const id of eventIds) { + this.selectedEventIds.add(id); + } + // Doesn't update activeEventId - this may also need + // changing, but depends on context. + } + @action.bound - setSelectedEventId(eventId: string | undefined) { - this.selectedEventId = eventId; + clearSelection() { + this.selectedEventIds.clear(); + this.activeEventId = undefined; } /** @@ -550,3 +598,4 @@ export class UiStore { } } + \ No newline at end of file diff --git a/src/model/ui/zip-export-formats.ts b/src/model/ui/zip-export-formats.ts new file mode 100755 index 00000000..281d3e89 --- /dev/null +++ b/src/model/ui/zip-export-formats.ts @@ -0,0 +1,200 @@ +/** + * Derivation layer for ZIP export formats. + * + * **No second global registry.** The existence, types, and client info of + * a snippet format are derived solely from `snippetExportOptions` in + * `./export.ts`, which in turn uses `HTTPSnippet.availableTargets()` as + * the single source of truth. + * + * This module only adds a small, UI-side overlay map for information that + * HTTPSnippet itself does not provide: + * - `popular` — pre-selected by default in the picker + * - `folderName` — stable, filesystem-friendly folder name + * - `extension` — file extension for the generated snippets + * + * When a new target is added to HTTPSnippet it appears automatically with + * safe defaults; the overlay map may be updated but does not have to be. + * When a target is removed, its entry disappears automatically as well. + */ +import * as _ from 'lodash'; + +import { + SnippetOption, + snippetExportOptions, + getCodeSnippetFormatKey, + getCodeSnippetFormatName +} from './export'; + +/** + * Overlay with UI-specific metadata per httpsnippet key (`target~~client`). + * + * `satisfies` ensures there are no typos in the entry field names and + * that `popular` is actually `boolean`; the mapping type accepts arbitrary + * keys so that a missing entry is not a type error (defaults apply). + */ +type FormatOverlay = { + folderName: string; + extension: string; + popular?: boolean; +}; + +const FORMAT_OVERLAY = { + // ── Shell ──────────────────────────────────────────────────────── + 'shell~~curl': { folderName: 'shell-curl', extension: 'sh', popular: true }, + 'shell~~httpie': { folderName: 'shell-httpie', extension: 'sh', popular: true }, + 'shell~~wget': { folderName: 'shell-wget', extension: 'sh' }, + + // ── JavaScript (Browser) ───────────────────────────────────────── + 'javascript~~fetch': { folderName: 'js-fetch', extension: 'js', popular: true }, + 'javascript~~xhr': { folderName: 'js-xhr', extension: 'js' }, + 'javascript~~jquery': { folderName: 'js-jquery', extension: 'js' }, + 'javascript~~axios': { folderName: 'js-axios', extension: 'js' }, + + // ── Node.js ────────────────────────────────────────────────────── + 'node~~fetch': { folderName: 'node-fetch', extension: 'js' }, + 'node~~axios': { folderName: 'node-axios', extension: 'js', popular: true }, + 'node~~native': { folderName: 'node-http', extension: 'js' }, + 'node~~request': { folderName: 'node-request', extension: 'js' }, + 'node~~unirest': { folderName: 'node-unirest', extension: 'js' }, + + // ── Python ─────────────────────────────────────────────────────── + 'python~~requests': { folderName: 'python-requests', extension: 'py', popular: true }, + 'python~~python3': { folderName: 'python-http', extension: 'py' }, + + // ── Java ───────────────────────────────────────────────────────── + 'java~~okhttp': { folderName: 'java-okhttp', extension: 'java', popular: true }, + 'java~~unirest': { folderName: 'java-unirest', extension: 'java' }, + 'java~~asynchttp': { folderName: 'java-asynchttp', extension: 'java' }, + 'java~~nethttp': { folderName: 'java-nethttp', extension: 'java' }, + + // ── Kotlin ─────────────────────────────────────────────────────── + 'kotlin~~okhttp': { folderName: 'kotlin-okhttp', extension: 'kt' }, + + // ── C# ─────────────────────────────────────────────────────────── + 'csharp~~restsharp': { folderName: 'csharp-restsharp', extension: 'cs' }, + 'csharp~~httpclient': { folderName: 'csharp-httpclient', extension: 'cs' }, + + // ── Go / PHP / Ruby / Rust / Swift / ObjC / C / R / OCaml / Clojure ─ + 'go~~native': { folderName: 'go-native', extension: 'go' }, + 'php~~curl': { folderName: 'php-curl', extension: 'php' }, + 'php~~http1': { folderName: 'php-http1', extension: 'php' }, + 'php~~http2': { folderName: 'php-http2', extension: 'php' }, + 'ruby~~native': { folderName: 'ruby-native', extension: 'rb' }, + 'ruby~~faraday': { folderName: 'ruby-faraday', extension: 'rb' }, + 'rust~~reqwest': { folderName: 'rust-reqwest', extension: 'rs' }, + 'swift~~nsurlsession': { folderName: 'swift-nsurlsession', extension: 'swift' }, + 'objc~~nsurlsession': { folderName: 'objc-nsurlsession', extension: 'm' }, + 'c~~libcurl': { folderName: 'c-libcurl', extension: 'c' }, + 'r~~httr': { folderName: 'r-httr', extension: 'r' }, + 'ocaml~~cohttp': { folderName: 'ocaml-cohttp', extension: 'ml' }, + 'clojure~~clj_http': { folderName: 'clojure-clj_http', extension: 'clj' }, + + // ── PowerShell ─────────────────────────────────────────────────── + 'powershell~~webrequest': { folderName: 'powershell-webrequest', extension: 'ps1', popular: true }, + 'powershell~~restmethod': { folderName: 'powershell-restmethod', extension: 'ps1' }, + + // ── HTTP ───────────────────────────────────────────────────────── + 'http~~1.1': { folderName: 'http-raw', extension: 'txt' } +} satisfies Record; + +/** + * Extended form of a `SnippetOption` with UI metadata. + * `id` is identical to `getCodeSnippetFormatKey(option)` (stable string). + */ +export interface ZipExportFormat extends SnippetOption { + id: string; + category: string; + folderName: string; + extension: string; + label: string; + popular: boolean; +} + +/** Safe defaults for new HTTPSnippet targets without an overlay entry. */ +function deriveFolderName(option: SnippetOption): string { + // Lowercase is consistent with all maintained overlay values and + // matches the convention for filesystem folders in the archive. + return `${option.target}-${option.client}` + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-'); +} +function deriveExtension(option: SnippetOption): string { + // Intentionally conservative: when we have no information, use .txt. + return 'txt'; +} + +/** Converts a `SnippetOption` + category into a `ZipExportFormat`. */ +function toZipExportFormat(option: SnippetOption, category: string): ZipExportFormat { + const id = getCodeSnippetFormatKey(option); + const overlay = (FORMAT_OVERLAY as Record)[id]; + return { + ...option, + id, + category, + label: getCodeSnippetFormatName(option), + folderName: overlay?.folderName ?? deriveFolderName(option), + extension: overlay?.extension ?? deriveExtension(option), + popular: overlay?.popular ?? false + }; +} + +/** + * Complete list of all currently available export formats, derived from + * `snippetExportOptions`. Stable ordering: categories alphabetically, + * within a category the HTTPSnippet order. + */ +export const ALL_ZIP_EXPORT_FORMATS: ReadonlyArray = _(snippetExportOptions) + .toPairs() + .flatMap(([category, options]) => options.map((o) => toZipExportFormat(o, category))) + .value(); + +/** Formats grouped by category, for UI rendering. */ +export const ZIP_EXPORT_FORMATS_BY_CATEGORY: Readonly> = + _.groupBy(ALL_ZIP_EXPORT_FORMATS, 'category'); + +/** Categories in display order. */ +export const ZIP_EXPORT_CATEGORIES: ReadonlyArray = + Object.keys(ZIP_EXPORT_FORMATS_BY_CATEGORY); + +/** Default pre-selected IDs ("Popular"). */ +export const DEFAULT_SELECTED_FORMAT_IDS: ReadonlySet = new Set( + ALL_ZIP_EXPORT_FORMATS.filter(f => f.popular).map(f => f.id) +); + +/** All IDs as a Set — useful for "Select all". */ +export const ALL_FORMAT_IDS: ReadonlySet = new Set( + ALL_ZIP_EXPORT_FORMATS.map(f => f.id) +); + +/** Fast lookup by ID. */ +export const FORMAT_BY_ID: ReadonlyMap = new Map( + ALL_ZIP_EXPORT_FORMATS.map(f => [f.id, f]) +); + +/** + * Resolves a set of IDs into format definitions. + * Unknown IDs are silently skipped (robust against persisted IDs left + * over from deleted HTTPSnippet targets after an update). + */ +export function resolveFormats(ids: Iterable): ZipExportFormat[] { + const result: ZipExportFormat[] = []; + for (const id of ids) { + const fmt = FORMAT_BY_ID.get(id); + if (fmt) result.push(fmt); + } + return result; +} + +/** + * Filters persisted IDs down to currently valid ones. Useful when + * hydrating the UI store: stale or broken IDs are discarded so the + * picker always starts in a consistent state. + */ +export function sanitizeFormatIds(ids: Iterable): string[] { + const out: string[] = []; + for (const id of ids) { + if (typeof id === 'string' && FORMAT_BY_ID.has(id)) out.push(id); + } + return out; +} + \ No newline at end of file diff --git a/src/model/ui/zip-export-service.ts b/src/model/ui/zip-export-service.ts new file mode 100755 index 00000000..6a083ebe --- /dev/null +++ b/src/model/ui/zip-export-service.ts @@ -0,0 +1,372 @@ +/** + * Orchestrates ZIP export from the UI: builds HAR, invokes the worker, + * tracks progress, and triggers the download. + * + * DEBUG: Filter browser console with "[ZIP]" to see each step. + */ +import { action, observable, runInAction, toJS } from 'mobx'; + +const ZIP_DEBUG = false; // set true to enable debug output (filter browser console with "[ZIP]") +function zipLog(step: string, ...args: unknown[]) { + if (!ZIP_DEBUG) return; + console.log( + `%c[ZIP] ${step}`, + 'color:#1e90ff;font-weight:bold', + ...args + ); +} +function zipWarn(step: string, ...args: unknown[]) { + if (!ZIP_DEBUG) return; + console.warn(`%c[ZIP] ${step}`, 'color:#ff8c00;font-weight:bold', ...args); +} +function zipError(step: string, ...args: unknown[]) { + if (!ZIP_DEBUG) return; + console.error(`%c[ZIP] ${step}`, 'color:#ff4444;font-weight:bold', ...args); +} + +import { CollectedEvent } from '../../types'; +import * as harModel from '../http/har'; +import { UI_VERSION } from '../../services/service-versions'; +import { logError } from '../../errors'; + +import * as workerApi from '../../services/ui-worker-api'; +import type { ZipExportFormatTriple } from '../../services/ui-worker'; + +import { + resolveFormats, + ZipExportFormat +} from './zip-export-formats'; +import { buildArchiveFilename } from '../../util/export-filenames'; + +export type ZipExportState = + | { kind: 'idle' } + | { kind: 'preparing' } + | { + kind: 'running', + percent: number, + stage: string, + currentRequest?: number, + totalRequests?: number + } + | { kind: 'cancelled' } + | { kind: 'error', message: string } + | { + kind: 'done', + snippetSuccessCount: number, + snippetErrorCount: number, + downloadUrl: string, + downloadName: string, + downloadBytes: number, + autoDownloadAttempted: boolean + }; + +type ZipExportDependencies = { + generateHar: typeof harModel.generateHar; + exportAsZip: typeof workerApi.exportAsZip; +}; + +const DEFAULT_ZIP_EXPORT_DEPENDENCIES: ZipExportDependencies = { + generateHar: harModel.generateHar, + exportAsZip: workerApi.exportAsZip +}; + +export class ZipExportController { + @observable state: ZipExportState = { kind: 'idle' }; + private abortController: AbortController | undefined; + private activeRunId = 0; + private activeDownloadUrl: string | undefined; + + constructor( + private readonly deps: ZipExportDependencies = DEFAULT_ZIP_EXPORT_DEPENDENCIES + ) {} + + get isBusy(): boolean { + return this.state.kind === 'preparing' || this.state.kind === 'running'; + } + + private invalidateActiveRun(): number { + this.activeRunId += 1; + return this.activeRunId; + } + + private isCurrentRun(runId: number, abortController?: AbortController): boolean { + return this.activeRunId === runId && + (!abortController || this.abortController === abortController); + } + + private abortActiveRun() { + const activeController = this.abortController; + this.abortController = undefined; + + if (activeController) { + try { activeController.abort(); } catch { /* noop */ } + } + } + + /** + * Revokes an existing blob URL if present. Must be called before a new + * export starts and when the object is disposed. + */ + private revokeActiveDownloadUrl() { + if (!this.activeDownloadUrl) return; + try { window.URL.revokeObjectURL(this.activeDownloadUrl); } catch { /* noop */ } + this.activeDownloadUrl = undefined; + } + + /** + * Cleans up blob URLs. Should be called on dialog unmount to prevent + * memory leaks. + */ + @action.bound + dispose() { + this.invalidateActiveRun(); + this.abortActiveRun(); + this.revokeActiveDownloadUrl(); + } + + @action.bound + cancel() { + this.invalidateActiveRun(); + this.abortActiveRun(); + this.revokeActiveDownloadUrl(); + + if (this.isBusy) { + this.state = { kind: 'cancelled' }; + } + } + + /** + * Snapshot-based export: event and format input is copied up front so + * later observable mutations cannot affect the in-flight run. + * + * Every invocation gets its own run id. Older async work is invalidated + * before the new run starts, so stale completions cannot update UI state + * or trigger downloads after cancel/retry/reset. + */ + @action.bound + async run(args: { + events: ReadonlyArray; + formatIds: Iterable; + snippetBodySizeLimit?: number; + }): Promise { + zipLog('run() started', { + eventCount: args.events.length, + formatIds: [...args.formatIds] + }); + + this.abortActiveRun(); + this.revokeActiveDownloadUrl(); + const runId = this.invalidateActiveRun(); + zipLog('runId assigned', runId); + + const eventsSnapshot = args.events.slice(); + zipLog('Step 1: events snapshot created', eventsSnapshot.length, 'events'); + + const formatSnapshot: ZipExportFormat[] = resolveFormats(args.formatIds); + zipLog('Step 2: formats resolved', formatSnapshot.map(f => f.id)); + + if (formatSnapshot.length === 0) { + zipWarn('Abort: no formats selected'); + if (this.isCurrentRun(runId)) { + this.state = { kind: 'error', message: 'No formats selected.' }; + } + return; + } + + const runAbortController = new AbortController(); + this.abortController = runAbortController; + this.state = { kind: 'preparing' }; + zipLog('Step 3: state -> preparing'); + + try { + zipLog('Step 4: generating HAR ...'); + const harObservable = await this.deps.generateHar(eventsSnapshot, { bodySizeLimit: Infinity }); + if (!this.isCurrentRun(runId, runAbortController)) { + zipWarn('Run stale after generateHar - aborting'); + return; + } + + const har = toJS(harObservable); + zipLog('Step 5: HAR ready', har.log.entries.length, 'entries'); + + if (!har.log.entries.length) { + zipWarn('Abort: HAR has no entries'); + runInAction(() => { + if (this.isCurrentRun(runId, runAbortController)) { + this.state = { + kind: 'error', + message: 'No exportable HTTP requests selected.' + }; + } + }); + return; + } + + const formats: ZipExportFormatTriple[] = formatSnapshot.map((f) => ({ + id: f.id, + target: f.target as string, + client: f.client as string, + category: f.category, + label: f.label, + folderName: f.folderName, + extension: f.extension + })); + zipLog('Step 6: ZipExportFormatTriple built', formats.map(f => f.id)); + + runInAction(() => { + if (!this.isCurrentRun(runId, runAbortController)) return; + this.state = { + kind: 'running', + percent: 0, + stage: 'preparing', + totalRequests: har.log.entries.length + }; + }); + zipLog('Step 7: state -> running (0%)'); + + zipLog('Step 8: calling exportAsZip() worker ...'); + const response = await this.deps.exportAsZip({ + har, + formats, + toolVersion: UI_VERSION, + signal: runAbortController.signal, + snippetBodySizeLimit: args.snippetBodySizeLimit, + onProgress: (p) => { + zipLog(` Progress: ${p.percent}% | Stage: ${p.stage} | Request: ${p.currentRequest ?? '-'}/${p.totalRequests ?? '-'}`); + runInAction(() => { + if (!this.isCurrentRun(runId, runAbortController)) return; + if (this.state.kind === 'running' || this.state.kind === 'preparing') { + this.state = { + kind: 'running', + percent: p.percent, + stage: p.stage, + currentRequest: p.currentRequest, + totalRequests: p.totalRequests + }; + } + }); + } + }); + zipLog('Step 9: worker response received', { + cancelled: response.cancelled, + archiveBytes: response.archive?.byteLength, + snippetSuccessCount: response.snippetSuccessCount, + snippetErrorCount: response.snippetErrorCount + }); + + if (!this.isCurrentRun(runId, runAbortController)) { + zipWarn('Run stale after worker - skipping download'); + return; + } + + if (response.cancelled) { + zipWarn('Step 10: worker reports cancelled'); + runInAction(() => { + if (this.isCurrentRun(runId, runAbortController)) { + this.state = { kind: 'cancelled' }; + } + }); + return; + } + + const filename = buildArchiveFilename(); + zipLog('Step 10: preparing download', filename, response.archive.byteLength, 'bytes'); + + // Always create blob + URL and keep it in state. This lets the UI + // offer a visible fallback link if Chrome rejects the programmatic + // download trigger due to missing user-gesture trust. + const blob = new Blob([response.archive], { type: 'application/zip' }); + const url = window.URL.createObjectURL(blob); + this.activeDownloadUrl = url; + + let autoDownloadAttempted = false; + try { + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.rel = 'noopener'; + // Off-screen instead of display:none - more reliable as click target. + a.style.position = 'fixed'; + a.style.top = '-9999px'; + a.style.left = '-9999px'; + a.style.opacity = '0'; + document.body.appendChild(a); + + // Chrome accepts a.click() as a download trigger (whitelisted) + // as long as a user gesture is still active. dispatchEvent() + // is only used as fallback if a.click() throws. + let dispatched = false; + try { + a.click(); + dispatched = true; + } catch (e) { + zipWarn('a.click() failed, trying dispatchEvent fallback', e); + try { + const clickEvent = new MouseEvent('click', { + view: window, + bubbles: true, + cancelable: true + }); + dispatched = a.dispatchEvent(clickEvent); + } catch (e2) { zipWarn('dispatchEvent fallback also failed', e2); } + } + autoDownloadAttempted = true; + zipLog('Step 10a: download trigger attempted', { + href: a.href, + download: a.download, + dispatched + }); + + // Remove anchor later. Do NOT revoke the blob URL here - we + // keep it on the controller until a new run starts or dispose() + // is called. This keeps the visible fallback link clickable. + setTimeout(() => { + try { document.body.removeChild(a); } catch { /* noop */ } + zipLog('Step 10b: anchor removed (blob URL kept for fallback link)'); + }, 1000); + } catch (downloadError) { + zipError('Programmatic download trigger failed - fallback link remains available', downloadError); + // No throw - the visible link in the 'done' state is sufficient. + } + + runInAction(() => { + if (!this.isCurrentRun(runId, runAbortController)) { + // Stale run: revoke URL immediately, do NOT touch own state. + try { window.URL.revokeObjectURL(url); } catch { /* noop */ } + if (this.activeDownloadUrl === url) this.activeDownloadUrl = undefined; + return; + } + this.state = { + kind: 'done', + snippetSuccessCount: response.snippetSuccessCount, + snippetErrorCount: response.snippetErrorCount, + downloadUrl: url, + downloadName: filename, + downloadBytes: response.archive.byteLength, + autoDownloadAttempted + }; + zipLog('Step 11: DONE', { + snippetSuccessCount: response.snippetSuccessCount, + snippetErrorCount: response.snippetErrorCount, + autoDownloadAttempted + }); + }); + } catch (e: any) { + if (!this.isCurrentRun(runId, runAbortController)) return; + + if (e && e.name === 'AbortError') { + zipWarn('Cancelled (AbortError)'); + runInAction(() => { + if (this.isCurrentRun(runId, runAbortController)) { + this.state = { kind: 'cancelled' }; + } + }); + return; + } + + zipError('ZIP export error', e); + logError(e); + runInAction(() => { + if (!this.isCurrentRun(runId, runAbortController)) return; + this.state = { + \ No newline at end of file diff --git a/src/model/ui/zip-manifest.ts b/src/model/ui/zip-manifest.ts new file mode 100755 index 00000000..fa4196e9 --- /dev/null +++ b/src/model/ui/zip-manifest.ts @@ -0,0 +1,78 @@ +/** + * Schema for the `manifest.json` located at the root of every ZIP export. + * + * Versioned (`version: 1`) so that consuming tools can reliably check + * compatibility. Replaces the earlier proprietary `_metadata.json` approach. + */ + +export const ZIP_EXPORT_MANIFEST_VERSION = 1; + +export interface ZipExportFormatEntry { + /** Stable ID (`target~~client`, e.g. `shell~~curl`). */ + id: string; + /** Category name (e.g. `Shell`). */ + category: string; + /** Folder name inside the ZIP archive. */ + folder: string; + /** File extension of the generated snippets. */ + extension: string; + /** Human-readable label. */ + label: string; + /** HTTPSnippet `target` / `client`. */ + target: string; + client: string; +} + +export interface ZipExportEntryRecord { + /** Filename in the respective format folder (without extension), e.g. `01_GET_example.com`. */ + file: string; + /** HAR request method. */ + method: string; + /** Request URL. */ + url: string; + /** HAR response status (`null` if the request was aborted or failed). */ + status: number | null; + /** Original event ID, if known. */ + eventId?: string; +} + +export interface ZipExportErrorRecord { + /** Base filename of the request in the ZIP (without extension). */ + file: string; + /** Stable format ID (`target~~client`) for which snippet generation failed. */ + formatId: string; + /** Human-readable label of the format (e.g. `Shell cURL`). */ + format?: string; + /** Original index in the HAR entry array (for locating the entry in the log). */ + entryIndex: number; + /** HTTP method of the request. */ + method: string; + /** Request URL. */ + url: string; + /** HTTP response status, if known. */ + status: number | null; + /** Error message from the HTTPSnippet converter. */ + error: string; +} + +export interface ZipExportManifest { + version: typeof ZIP_EXPORT_MANIFEST_VERSION; + /** ISO timestamp of generation. */ + generatedAt: string; + /** Name of the tool that created the export. */ + tool: 'httptoolkit-ui'; + /** UI version that created the export (from `UI_VERSION`). */ + toolVersion: string; + /** Number of requests in the export. */ + requestCount: number; + /** List of included formats. */ + formats: ZipExportFormatEntry[]; + /** Per-request metadata (method, URL, status). */ + entries: ZipExportEntryRecord[]; + /** + * Per-snippet errors for snippets that could not be generated (partial failure). + * Empty if the export completed without errors. + */ + errors: ZipExportErrorRecord[]; + /** Name of the HAR file included in the archive, if the HAR was bundled separately. */ + \ No newline at end of file diff --git a/src/services/ui-worker-api.ts b/src/services/ui-worker-api.ts index 5c04e770..09a4b4a2 100644 --- a/src/services/ui-worker-api.ts +++ b/src/services/ui-worker-api.ts @@ -1,5 +1,6 @@ import deserializeError from 'deserialize-error'; import { EventEmitter } from 'events'; +import type { Har } from 'har-format'; import type { SUPPORTED_ENCODING } from 'http-encoding'; import type { @@ -19,14 +20,16 @@ import type { FormatResponse, ParseCertRequest, ParseCertResponse, - GenerateZipRequest, - GenerateZipResponse + ZipExportRequest, + ZipExportResponse, + ZipExportProgressMessage, + ZipExportFormatTriple, + ZipExportPrewarmRequest, + ZipExportPrewarmResponse } from './ui-worker'; import { Headers, Omit } from '../types'; import type { ApiMetadata, ApiSpec } from '../model/api/api-interfaces'; -import type { SnippetFormatDefinition } from '../model/ui/snippet-formats'; -import type { ZipMetadata } from '../model/ui/zip-metadata'; import { WorkerFormatterKey } from './ui-worker-formatters'; import { decodingRequired } from '../model/events/bodies'; @@ -38,43 +41,137 @@ function getId() { } const emitter = new EventEmitter(); +const progressEmitter = new EventEmitter(); worker.addEventListener('message', (event) => { - emitter.emit(event.data.id.toString(), event.data); + const data = event.data; + if (data && data.type === 'zip-export-progress') { + progressEmitter.emit(data.id.toString(), data); + return; + } + emitter.emit(data.id.toString(), data); }); +/** + * Additional options for long-running requests (ZIP export, potentially + * others). Intentionally optional — no existing `callApi` call needs to + * be modified. + */ +export interface CallApiOptions { + signal?: AbortSignal; + onProgress?: (msg: ZipExportProgressMessage) => void; + cancelChannel?: boolean; + /** + * Hard timeout in ms. If the worker does not respond within this window, + * the call is rejected and (if `cancelChannel` is active) an abort is + * sent to the worker. `undefined` = no timeout. + */ + timeoutMs?: number; +} + function callApi< T extends BackgroundRequest, R extends BackgroundResponse ->(request: Omit, transfer: any[] = []): Promise { +>( + request: Omit, + transfer: any[] = [], + options?: CallApiOptions +): Promise { const id = getId(); return new Promise((resolve, reject) => { - worker.postMessage(Object.assign({ id }, request), transfer); + let cancelChannel: MessageChannel | undefined; + let finalized = false; + let progressHandler: ((data: ZipExportProgressMessage) => void) | undefined; + let abortListener: (() => void) | undefined; + let timeoutHandle: ReturnType | undefined; + let responseHandler: ((data: R) => void) | undefined; + const signal = options?.signal; + + const finalize = () => { + if (finalized) return; + finalized = true; + if (progressHandler) { + progressEmitter.off(id.toString(), progressHandler); + } + if (responseHandler) { + emitter.off(id.toString(), responseHandler); + } + if (abortListener && signal) { + try { signal.removeEventListener('abort', abortListener); } catch {} + } + if (timeoutHandle !== undefined) { + clearTimeout(timeoutHandle); + timeoutHandle = undefined; + } + try { cancelChannel?.port1.close(); } catch {} + }; - emitter.once(id.toString(), (data: R) => { + // Spread request first, then override with generated id to prevent + // any request.id from accidentally overwriting the correlation id. + let payload: any = { ...request, id }; + const transferList = transfer.slice(); + if (options?.cancelChannel) { + cancelChannel = new MessageChannel(); + payload.cancelPort = cancelChannel.port2; + transferList.push(cancelChannel.port2); + } + + if (signal) { + if (signal.aborted) { + finalize(); + reject(new DOMException('Aborted', 'AbortError')); + return; + } + abortListener = () => { + try { cancelChannel?.port1.postMessage({ type: 'abort' }); } catch {} + // Also reject immediately so the main thread is not blocked + // waiting for the worker if it is stuck before its next yield. + finalize(); + reject(new DOMException('Aborted', 'AbortError')); + }; + signal.addEventListener('abort', abortListener, { once: true }); + } + + if (options?.onProgress) { + progressHandler = (data: ZipExportProgressMessage) => { + options.onProgress!(data); + }; + progressEmitter.on(id.toString(), progressHandler); + } + + if (typeof options?.timeoutMs === 'number' && options.timeoutMs > 0) { + timeoutHandle = setTimeout(() => { + // Proactively try to stop the worker; if it cooperates, + // this will result in a cancelled response and finalize + // runs normally. If not, reject directly — the worker + // can be ignored afterwards. + try { cancelChannel?.port1.postMessage({ type: 'abort' }); } catch {} + finalize(); + reject(new Error( + `Worker call (${(request as any).type}) timed out after ${options.timeoutMs}ms` + )); + }, options.timeoutMs); + } + + // Register the response handler BEFORE postMessage so that an + // (unlikely) synchronous worker response cannot be missed. + responseHandler = (data: R) => { + finalize(); if (data.error) { reject(deserializeError(data.error)); } else { resolve(data); } - }); + }; + emitter.once(id.toString(), responseHandler); + + worker.postMessage(payload, transferList); }); } -/** - * Takes a body, asynchronously decodes it and returns the decoded buffer. - * - * Note that this requires transferring the _encoded_ body to a web worker, - * so after this is run the encoded the buffer will become empty, if any - * decoding is actually required. - * - * The method returns an object containing the new decoded buffer and the - * original encoded data (transferred back) in a new buffer. - */ export async function decodeBody(encodedBuffer: Buffer, encodings: string[]) { if (!decodingRequired(encodedBuffer, encodings)) { - // Shortcut to skip decoding when we know it's not required: return { encoded: encodedBuffer, decoded: encodedBuffer }; } @@ -90,8 +187,6 @@ export async function decodeBody(encodedBuffer: Buffer, encodings: string[]) { decoded: Buffer.from(result.decodedBuffer) }; } catch (e: any) { - // In general, the worker should return the original encoded buffer to us, so we can - // show it to the user to help them debug encoding issues: if (e.inputBuffer) { e.inputBuffer = Buffer.from(e.inputBuffer); } @@ -104,7 +199,6 @@ export async function encodeBody(decodedBuffer: Buffer, encodings: string[]) { encodings.length === 0 || (encodings.length === 1 && encodings[0] === 'identity') ) { - // Shortcut to skip encoding when we know it's not required return decodedBuffer; } @@ -159,108 +253,37 @@ export async function formatBufferAsync(buffer: Buffer, format: WorkerFormatterK })).formatted; } -export interface ZipProgressInfo { - phase: string; - completed: number; - total: number; - percent: number; -} - -export interface ZipResult { - buffer: ArrayBuffer; - /** Number of snippet generations that failed (see _errors.json in the archive) */ - snippetErrors: number; - /** Total number of snippet generations attempted */ - totalSnippets: number; -} +/** + * Hard timeout for ZIP exports. Even very large exports (5k requests × + * 37 formats ≈ 185k snippets) complete in a few seconds; 5 minutes is + * a safeguard against a hung worker, not an expected upper bound. + */ +const ZIP_EXPORT_TIMEOUT_MS = 5 * 60 * 1000; /** - * Generates a ZIP archive containing code snippets in all formats plus - * the HAR data and metadata. All CPU-intensive work runs in the Web Worker. - * - * @param harEntries - Plain (non-MobX-proxy) HAR entry objects. Use toJS() before calling. - * @param formats - Which snippet formats to include. - * @param metadata - The ZipMetadata object for _metadata.json. - * @param onProgress - Optional callback invoked with progress updates (~every 5%). - * @returns ZipResult with the compressed archive buffer and snippet error counts. + * Pre-warms the ZIP export hot path in the worker. Idempotent, very + * cheap, fire-and-forget in the UI. Drastically reduces perceived + * latency on the first "Download ZIP" click because HTTPSnippet + fflate + * are already JIT-compiled by then. */ -export function generateZipInWorker( - harEntries: any[], - formats: SnippetFormatDefinition[], - metadata: ZipMetadata, - onProgress?: (info: ZipProgressInfo) => void -): Promise { - if (harEntries.length === 0) { - return Promise.reject(new Error('No entries to export')); - } - if (formats.length === 0) { - return Promise.reject(new Error('No formats selected')); +export async function prewarmZipExport(): Promise { + try { + await callApi({ + type: 'zip-export-prewarm' + }); + } catch { + // Ignore prewarm errors — the actual export will surface a + // real error that we can display to the user. } +} - const id = getId(); - - return new Promise((resolve, reject) => { - let settled = false; - const cleanup = () => { - worker.removeEventListener('message', handler); - clearTimeout(timeoutId); - }; - - // Safety timeout: if the worker doesn't respond within 5 minutes, - // clean up the listener to prevent memory leaks. - const timeoutId = setTimeout(() => { - if (!settled) { - settled = true; - cleanup(); - reject(new Error('ZIP generation timed out after 5 minutes')); - } - }, 5 * 60 * 1000); - - const handler = (event: MessageEvent) => { - const data = event.data; - if (data.id !== id) return; - - // Progress messages have a 'type' field; final responses don't - if (data.type === 'generateZipProgress') { - onProgress?.({ - phase: data.phase, - completed: data.completed, - total: data.total, - percent: data.percent - }); - return; // Keep listening for the final result - } - - // Final result — always remove listener before resolving/rejecting - if (settled) return; - settled = true; - cleanup(); - - if (data.error) { - reject(deserializeError(data.error)); - } else { - resolve({ - buffer: data.buffer, - snippetErrors: data.snippetErrors || 0, - totalSnippets: data.totalSnippets || 0 - }); - } - }; - - worker.addEventListener('message', handler); - - try { - worker.postMessage(Object.assign({ id }, { - type: 'generateZip', - harEntries, - formats, - metadata - } as Omit)); - } catch (err) { - // postMessage can throw for unserializable data (MobX proxies, etc.) - settled = true; - cleanup(); - reject(err); - } - }); -} \ No newline at end of file +export async function exportAsZip(args: { + har: Har; + formats: ZipExportFormatTriple[]; + toolVersion: string; + signal?: AbortSignal; + onProgress?: (p: ZipExportProgressMessage) => void; + snippetBodySizeLimit?: number; +}): Promise { + try { + \ No newline at end of file diff --git a/src/services/ui-worker.ts b/src/services/ui-worker.ts index 277e0880..8924aece 100644 --- a/src/services/ui-worker.ts +++ b/src/services/ui-worker.ts @@ -12,17 +12,28 @@ import { SUPPORTED_ENCODING } from 'http-encoding'; import { OpenAPIObject } from 'openapi-directory'; -import * as HTTPSnippet from '@httptoolkit/httpsnippet'; -import { zip } from 'fflate'; import { Headers } from '../types'; import { ApiMetadata, ApiSpec } from '../model/api/api-interfaces'; import { buildOpenApiMetadata, buildOpenRpcMetadata } from '../model/api/build-api-metadata'; import { parseCert, ParsedCertificate, validatePKCS12, ValidationResult } from '../model/crypto'; import { WorkerFormatterKey, formatBuffer } from './ui-worker-formatters'; -import { buildZipFileName } from '../util/export-filenames'; -import type { SnippetFormatDefinition } from '../model/ui/snippet-formats'; -import type { ZipMetadata } from '../model/ui/zip-metadata'; +import type * as HarFormat from 'har-format'; +import type { Har } from 'har-format'; +import * as HTTPSnippet from '@httptoolkit/httpsnippet'; +import { zipSync, strToU8, type Zippable } from 'fflate'; +import { + buildRequestBaseName, + buildSnippetZipPath +} from '../util/export-filenames'; +import { + ZIP_EXPORT_MANIFEST_VERSION, + ZipExportManifest, + ZipExportEntryRecord, + ZipExportErrorRecord, + ZipExportFormatEntry +} from '../model/ui/zip-manifest'; +import { simplifyHarEntryRequestForSnippetExport } from '../model/ui/snippet-export-sanitization'; interface Message { id: number; @@ -105,16 +116,59 @@ export interface FormatResponse extends Message { formatted: string; } -export interface GenerateZipRequest extends Message { - type: 'generateZip'; - harEntries: any[]; - formats: SnippetFormatDefinition[]; - metadata: ZipMetadata; + +export interface ZipExportFormatTriple { + id: string; + target: string; + client: string; + category: string; + label: string; + folderName: string; + extension: string; +} + +export interface ZipExportProgressMessage { + id: number; + type: 'zip-export-progress'; + percent: number; + stage: 'preparing' | 'generating' | 'finalizing'; + currentRequest?: number; + totalRequests?: number; +} + +export interface ZipExportRequest extends Message { + type: 'zip-export'; + har: Har; + formats: ZipExportFormatTriple[]; + toolVersion: string; + snippetBodySizeLimit?: number; + cancelPort?: MessagePort; } -export interface GenerateZipResponse extends Message { +export interface ZipExportResponse extends Message { error?: Error; - buffer: ArrayBuffer; + archive: ArrayBuffer; + cancelled: boolean; + snippetSuccessCount: number; + snippetErrorCount: number; +} + +/** + * Pre-warm for the ZIP export: initializes HTTPSnippet, fflate, and the + * rest of the export hot path before the user clicks "Download ZIP". + * Makes the actual export noticeably faster because the expensive first- + * time initialization of HTTPSnippet + DEFLATE tables has already run in + * the background while the user is still selecting formats. + * Idempotent — repeated calls are cheap (just a tiny throwaway snippet + + * zipSync on an empty root). + */ +export interface ZipExportPrewarmRequest extends Message { + type: 'zip-export-prewarm'; +} + +export interface ZipExportPrewarmResponse extends Message { + error?: Error; + warmed: true; } export type BackgroundRequest = @@ -125,7 +179,8 @@ export type BackgroundRequest = | ValidatePKCSRequest | ParseCertRequest | FormatRequest - | GenerateZipRequest; + | ZipExportRequest + | ZipExportPrewarmRequest; export type BackgroundResponse = | DecodeResponse @@ -135,7 +190,8 @@ export type BackgroundResponse = | ValidatePKCSResponse | ParseCertResponse | FormatResponse - | GenerateZipResponse; + | ZipExportResponse + | ZipExportPrewarmResponse; const bufferToArrayBuffer = (buffer: Buffer): ArrayBuffer => // Have to remember to slice: this can be a view into part of a much larger buffer! @@ -192,6 +248,540 @@ async function buildApi(request: BuildApiRequest): Promise { }; } + +/** + * Places `data` at `path` (POSIX-style, `/`-separated) in the `Zippable` + * tree. Throws on structural collisions to avoid silently overwriting data: + * + * - Folder-vs-File: if a segment on the way to the leaf already exists + * as a file (Uint8Array), descending into it would destroy the file. + * - File-vs-Folder: if the leaf already exists as an object (because a + * deeper path was created under this name), overwriting would lose the + * subtree. + * - Leaf duplicate: if this exact path is already populated, we would + * silently replace an earlier entry. + * + * The caller (handleZipExport) proactively resolves duplicate basenames + * via reserveZipPath() before calling placeInZip(), so the third condition + * should never fire in practice, but is included as a safety net. + */ +function placeInZip(zip: Zippable, path: string, data: Uint8Array): void { + const parts = path.split('/').filter(Boolean); + if (parts.length === 0) { + throw new Error('ZIP placement: empty path'); + } + let cur: any = zip; + for (let i = 0; i < parts.length - 1; i++) { + const seg = parts[i]; + const existing = cur[seg]; + if (existing === undefined) { + cur[seg] = {}; + } else if (existing instanceof Uint8Array) { + throw new Error( + `ZIP placement collision: '${seg}' is a file, cannot descend into it (full path: ${path})` + ); + } + cur = cur[seg]; + } + const leaf = parts[parts.length - 1]; + const existingLeaf = cur[leaf]; + if (existingLeaf !== undefined) { + if (existingLeaf instanceof Uint8Array) { + throw new Error(`ZIP placement collision: '${path}' already exists as file`); + } + throw new Error(`ZIP placement collision: '${path}' already exists as folder`); + } + cur[leaf] = data; +} + +/** + * Per-ZIP path reservation. Returns a unique path in the archive by + * appending _2, _3, ... to the filename (before the extension) on + * collision. The folder + file + extension separation is preserved. + * + * Used in handleZipExport() with a fresh Set per run, so there are no + * collisions between different exports. + */ +function reserveZipPath(taken: Set, folder: string, base: string, ext: string): string { + const extPart = ext ? `.${ext}` : ''; + let candidate = `${folder}/${base}${extPart}`; + if (!taken.has(candidate)) { + taken.add(candidate); + return candidate; + } + // Duplicate - find the next free suffix. + for (let n = 2; n < 10000; n++) { + candidate = `${folder}/${base}_${n}${extPart}`; + if (!taken.has(candidate)) { + taken.add(candidate); + return candidate; + } + } + // Pathological fallback: use a random suffix, better than overwriting. + candidate = `${folder}/${base}_${Date.now()}${extPart}`; + taken.add(candidate); + return candidate; +} + +/** + * Body length in bytes (UTF-8), falls back to string length if + * TextEncoder is unavailable. Used only for the truncation cap. + */ +function byteLength(s: string): number { + try { + return new TextEncoder().encode(s).byteLength; + } catch { + return s.length; + } +} + +/** + * Replaces the body of a HAR request object with a placeholder if the + * body exceeds `limit` bytes. The placeholder points to the full + * requests.har file at the ZIP root. + */ +function truncateRequestBody( + harRequest: HarFormat.Request, + limit: number +): HarFormat.Request { + if (!harRequest.postData || typeof harRequest.postData.text !== 'string') { + return harRequest; + } + const len = byteLength(harRequest.postData.text); + if (len <= limit) return harRequest; + + return { + ...harRequest, + postData: { + ...harRequest.postData, + text: `!!! REQUEST BODY TRUNCATED (${len} bytes) - SEE requests.har FOR FULL BODY !!!` + } + }; +} + +/** + * Filters header/query entries whose `name` or `value` is not a string. + * For example, clj-http calls `value.constructor.name` on every value + * and crashes hard on `null`/`undefined`. + */ +function filterStringKV( + xs: readonly T[] | undefined +): T[] { + return Array.isArray(xs) + ? xs.filter(x => typeof x?.name === 'string' && typeof x?.value === 'string') + : []; +} + +/** + * Builds a "reduced" HAR request for HTTPSnippet targets that crash on + * richer postData shapes (notably clj-http). The result keeps method/URL/ + * headers/queryString but reduces `postData` to `{ mimeType, text }` and + * removes anything that some targets expect as an array/object but find null. + */ +function buildReducedRequest(source: HarFormat.Request): HarFormat.Request { + let postData = source.postData; + if (postData) { + const safeText = typeof (postData as any).text === 'string' + ? (postData as any).text + : ''; + const safeMime = typeof (postData as any).mimeType === 'string' && (postData as any).mimeType + ? (postData as any).mimeType + : 'application/octet-stream'; + postData = { mimeType: safeMime, text: safeText } as HarFormat.PostData; + } + return { + method: source.method || 'GET', + url: source.url || 'about:blank', + httpVersion: source.httpVersion || 'HTTP/1.1', + headers: filterStringKV(source.headers), + queryString: filterStringKV(source.queryString), + cookies: [], + headersSize: -1, + bodySize: typeof source.bodySize === 'number' ? source.bodySize : 0, + postData + }; +} + +/** + * Ultra-conservative fallback: forces the body to `text/plain` so that + * null-sensitive targets like clj-http do not attempt to parse or + * recursively descend into the body. Hardens against the specific bug + * where clj-http crashes on `JSON.parse(text) === null` (e.g. GraphQL + * `variables: null`, `persistedQuery: null`) in + * `jsType(null).constructor.name`. Also covers other targets that crash + * on null values in body/headers/query. + */ +function buildUltraSafeRequest(source: HarFormat.Request): HarFormat.Request { + const rawText = typeof (source as any)?.postData?.text === 'string' + ? (source as any).postData.text + : ''; + return { + method: typeof source.method === 'string' ? source.method : 'GET', + url: typeof source.url === 'string' ? source.url : 'about:blank', + httpVersion: typeof source.httpVersion === 'string' ? source.httpVersion : 'HTTP/1.1', + headers: filterStringKV(source.headers), + queryString: filterStringKV(source.queryString), + cookies: [], + headersSize: -1, + bodySize: typeof source.bodySize === 'number' ? source.bodySize : 0, + // Force text/plain: targets then take the body-string path and + // avoid deep-object descents on potential null values. + // Conditional spread keeps the field absent (rather than + // `undefined`) when there is no body — fully type-safe. + ...(rawText + ? { postData: { mimeType: 'text/plain', text: rawText } as HarFormat.PostData } + : {}), + }; +} + +/** + * Heuristic: is the error thrown by the HTTPSnippet target a known + * "null-shape" TypeError class where a retry with a reduced request + * realistically has a chance of success? + */ +function isRecoverableSnippetError(e: any): boolean { + const msg = String(e?.message ?? e ?? ''); + // Observed with clj-http (null postData.params / null jsonObj / + // null values in allHeaders). The heuristic rule also covers + // similar "reading 'xxx' of null/undefined" cases in other targets. + return ( + e instanceof TypeError + || /Cannot read propert(y|ies) of (null|undefined)/.test(msg) + ); +} + +async function handleZipExport(request: ZipExportRequest): Promise { + const { id, har, formats, toolVersion, cancelPort, snippetBodySizeLimit } = request; + + let cancelled = false; + if (cancelPort) { + cancelPort.onmessage = (e: MessageEvent) => { + if (e.data?.type === 'abort') cancelled = true; + }; + // Port must be started in Worker context + try { (cancelPort as any).start?.(); } catch {} + } + + const entries = har.log.entries; + const total = entries.length; + const formatCount = formats.length; + + if (formatCount === 0) { + throw new Error('No formats selected for ZIP export'); + } + + if (total === 0) { + throw new Error('No HTTP requests available for ZIP export'); + } + + const progress = (percent: number, stage: 'preparing' | 'generating' | 'finalizing', currentRequest?: number) => { + const msg: ZipExportProgressMessage = { + id, + type: 'zip-export-progress', + percent, + stage, + currentRequest, + totalRequests: total + }; + ctx.postMessage(msg); + }; + + progress(0, 'preparing'); + + // The original HAR goes into the ZIP unchanged (as an archive + // reference for the truncation placeholder). For snippet generation + // we work on sanitized copies. + const zipRoot: Zippable = {}; + const manifestEntries: ZipExportEntryRecord[] = []; + const manifestErrors: ZipExportErrorRecord[] = []; + let snippetSuccessCount = 0; + let snippetErrorCount = 0; + // Reserves all already-assigned ZIP paths for this run, so that + // two requests with the same sanitized basename (e.g. after 120-char + // truncation) cannot overwrite each other. The reserved top-level + // names must not be accidentally clobbered by snippets. + const usedZipPaths: Set = new Set([ + 'requests.har', + 'manifest.json', + '_errors.json' + ]); + + // Yield helper: releases the event loop so that cancelPort.onmessage + // can fire. Without this yield, the entire generation loop would run + // synchronously and cancel would be completely ineffective. + const yieldToEventLoop = (): Promise => new Promise(r => setTimeout(r, 0)); + + for (let i = 0; i < total; i++) { + if (cancelled) break; + + // Yield every 5 requests so that cancel messages can reach the + // worker. For large exports (5000+ requests), yielding per request + // would be too expensive (~4ms overhead per setTimeout on some + // platforms). + if (cancelPort && (i % 5 === 0)) { + await yieldToEventLoop(); + if (cancelled) break; + } + + const entry = entries[i]; + const rawReq = entry?.request; + const fallbackReq: HarFormat.Request = { + method: 'GET', + url: 'about:blank', + httpVersion: 'HTTP/1.1', + headers: [], + queryString: [], + cookies: [], + headersSize: -1, + bodySize: 0 + }; + const baseReq: HarFormat.Request = rawReq ?? fallbackReq; + + // Single source of truth: same filter as single-snippet export. + const cleanedReq = simplifyHarEntryRequestForSnippetExport(baseReq); + const finalReq = typeof snippetBodySizeLimit === 'number' && snippetBodySizeLimit > 0 + ? truncateRequestBody(cleanedReq, snippetBodySizeLimit) + : cleanedReq; + + const status = entry?.response?.status ?? null; + const baseName = buildRequestBaseName({ + index: i, + total, + method: finalReq.method || 'GET', + url: finalReq.url || 'about:blank', + status + }); + + const entryRecord: ZipExportEntryRecord = { + file: baseName, + method: finalReq.method || 'GET', + url: finalReq.url || 'about:blank', + status + }; + + // Perf hotspot #1: build HTTPSnippet once per request - parsing + + // normalizing is expensive; `convert()` is the cheap part. An + // earlier implementation re-instantiated per format, inflating + // runtime linearly with formatCount. + const snippet = new HTTPSnippet(finalReq); + + // Lazy fallback snippets: some HTTPSnippet targets (e.g. + // clj-http) crash on certain `postData` shapes with "Cannot read + // properties of null". We retry in two stages: + // 1. reducedSnippet: stripped postData (mimeType + text), + // filtered header/query without null values. + // 2. ultraSafeSnippet: forces text/plain on the body so that + // targets no longer trigger the JSON descent. + // Both are only instantiated on demand. + let reducedSnippet: HTTPSnippet | null = null; + const getReducedSnippet = (): HTTPSnippet => { + if (reducedSnippet) return reducedSnippet; + reducedSnippet = new HTTPSnippet(buildReducedRequest(finalReq)); + return reducedSnippet; + }; + let ultraSafeSnippet: HTTPSnippet | null = null; + const getUltraSafeSnippet = (): HTTPSnippet => { + if (ultraSafeSnippet) return ultraSafeSnippet; + ultraSafeSnippet = new HTTPSnippet(buildUltraSafeRequest(finalReq)); + return ultraSafeSnippet; + }; + + for (let f = 0; f < formatCount; f++) { + if (cancelled) break; + const fmt = formats[f]; + // buildSnippetZipPath sanitizes folder+file+extension individually + // (no path traversal possible). reserveZipPath adds duplicate + // resolution over the already-assigned path space. + const templatePath = buildSnippetZipPath(fmt.folderName, baseName, fmt.extension); + const slashIdx = templatePath.lastIndexOf('/'); + const folderPart = slashIdx >= 0 ? templatePath.slice(0, slashIdx) : ''; + const filePart = slashIdx >= 0 ? templatePath.slice(slashIdx + 1) : templatePath; + const dotIdx = filePart.lastIndexOf('.'); + const basePart = dotIdx > 0 ? filePart.slice(0, dotIdx) : filePart; + const extPart = dotIdx > 0 ? filePart.slice(dotIdx + 1) : ''; + const zipPath = reserveZipPath(usedZipPaths, folderPart, basePart, extPart); + try { + // HTTPSnippet types convert() as string, but the JS + // implementation returns false for unknown targets/clients + // (cf. Kong/httpsnippet#298). We therefore defensively + // check for non-string / empty-string results. + let snippetRaw: unknown; + try { + snippetRaw = snippet.convert(fmt.target as HTTPSnippet.Target, fmt.client); + } catch (primaryErr: any) { + // Known HTTPSnippet bug: some targets (clj-http et al.) + // throw `Cannot read properties of null (reading + // 'constructor')` when the body shape does not match + // their expectations exactly. We retry in two stages: + // Stage 1: reducedSnippet (stripped postData, + // filtered header/query). + // Stage 2: ultraSafeSnippet (forces text/plain) if + // stage 1 still crashes due to nested-null + // in the JSON body (e.g. GraphQL + // `variables: null`). + if (!isRecoverableSnippetError(primaryErr)) { + throw primaryErr; + } + try { + snippetRaw = getReducedSnippet().convert( + fmt.target as HTTPSnippet.Target, + fmt.client + ); + } catch (secondErr: any) { + if (!isRecoverableSnippetError(secondErr)) { + throw secondErr; + } + snippetRaw = getUltraSafeSnippet().convert( + fmt.target as HTTPSnippet.Target, + fmt.client + ); + } + } + if (typeof snippetRaw !== 'string' || snippetRaw.length === 0) { + throw new Error( + `HTTPSnippet produced no output for ${fmt.target}/${fmt.client}` + ); + } + placeInZip(zipRoot, zipPath, strToU8(snippetRaw)); + snippetSuccessCount++; + } catch (e: any) { + manifestErrors.push({ + file: baseName, + formatId: fmt.id, + format: fmt.label, + entryIndex: i, + method: finalReq.method || 'GET', + url: finalReq.url || 'about:blank', + status, + error: String(e?.message ?? e) + }); + snippetErrorCount++; + } + } + + manifestEntries.push(entryRecord); + const percent = Math.round(((i + 1) / total) * 90); + progress(percent, 'generating', i + 1); + } + + if (cancelled) { + const response: ZipExportResponse = { + id, + archive: new ArrayBuffer(0), + cancelled: true, + snippetSuccessCount, + snippetErrorCount + }; + ctx.postMessage(response, []); + return; + } + + progress(92, 'finalizing'); + + // The complete, unmodified HAR (including original bodies) is + // included in the archive; snippet placeholders point to it on + // truncation. Compact JSON (no pretty-print) halves bytes and + // stringify time. Anyone inspecting it has a JSON viewer anyway. + placeInZip(zipRoot, 'requests.har', strToU8(JSON.stringify(har))); + + progress(94, 'finalizing'); + + const formatsEntry: ZipExportFormatEntry[] = formats.map(f => ({ + id: f.id, + target: f.target, + client: f.client, + category: f.category, + label: f.label, + folder: f.folderName, + extension: f.extension + })); + + const manifest: ZipExportManifest = { + version: ZIP_EXPORT_MANIFEST_VERSION, + generatedAt: new Date().toISOString(), + tool: 'httptoolkit-ui', + toolVersion, + requestCount: total, + formats: formatsEntry, + entries: manifestEntries, + errors: manifestErrors, + harFile: 'requests.har' + }; + + // manifest.json stays pretty-printed: small, but often manually inspected. + placeInZip(zipRoot, 'manifest.json', strToU8(JSON.stringify(manifest, null, 2))); + + // Errors also written as standalone _errors.json, making post-mortem + // analysis easier without parsing the full manifest.json. + if (manifestErrors.length > 0) { + placeInZip( + zipRoot, + '_errors.json', + strToU8(JSON.stringify({ errors: manifestErrors }, null, 2)) + ); + } + + progress(96, 'finalizing'); + + // Perf hotspot #2 (round 2): STORE mode. No DEFLATE at all. + // Snippet files are tiny and their combined size is well below the + // point where compression justifies user wait time. Prioritizing + // speed >> archive size: level 0 packs in milliseconds instead of + // seconds, the archive is 2-3x larger depending on content but is + // delivered instantly. + const archiveBytes = zipSync(zipRoot, { level: 0 }); + + progress(98, 'finalizing'); + + const archiveBuffer = archiveBytes.buffer.slice( + archiveBytes.byteOffset, + archiveBytes.byteOffset + archiveBytes.byteLength + ) as ArrayBuffer; + + progress(100, 'finalizing'); + + const response: ZipExportResponse = { + id, + archive: archiveBuffer, + cancelled: false, + snippetSuccessCount, + snippetErrorCount + }; + + ctx.postMessage(response, [response.archive]); +} + +/** + * Warms up the ZIP export hot path. Runs a tiny dummy snippet convert + * and a dummy zipSync so that the real export has all JIT and module + * init costs already paid. + */ +let prewarmed = false; +function prewarmZipExportPath(): void { + if (prewarmed) return; + try { + const dummyHar: HarFormat.Request = { + method: 'GET', + url: 'https://example.invalid/', + httpVersion: 'HTTP/1.1', + headers: [], + queryString: [], + cookies: [], + headersSize: -1, + bodySize: 0 + }; + // Force HTTPSnippet constructor + convert path to load. + const snippet = new HTTPSnippet(dummyHar); + snippet.convert('shell' as HTTPSnippet.Target, 'curl'); + // Force fflate DEFLATE tables init via a trivial zipSync call. + zipSync({ 'prewarm.txt': strToU8('x') }, { level: 0 }); + prewarmed = true; + } catch { + // Prewarm errors are never fatal — the actual export will + // report the error again. + } +} + ctx.addEventListener('message', async (event: { data: BackgroundRequest }) => { try { switch (event.data.type) { @@ -247,191 +837,9 @@ ctx.addEventListener('message', async (event: { data: BackgroundRequest }) => { ctx.postMessage({ id: event.data.id, formatted }); break; - case 'generateZip': { - const { id, harEntries, formats, metadata } = event.data as GenerateZipRequest; - - try { - if (!harEntries || harEntries.length === 0) { - throw new Error('No HAR entries provided for ZIP export'); - } - if (!formats || formats.length === 0) { - throw new Error('No snippet formats selected for ZIP export'); - } - - const encoder = new TextEncoder(); - const files: Record = {}; - const totalSteps = formats.length * harEntries.length; - let completedSteps = 0; - let lastReportedPercent = 0; - - // Track snippet generation errors for transparency - const snippetErrors: Array<{ - format: string; - entryIndex: number; - method: string; - url: string; - error: string; - }> = []; - - // 1. Generate snippet files for each format × each entry - for (const format of formats) { - for (let i = 0; i < harEntries.length; i++) { - const entry = harEntries[i]; - try { - // HTTPSnippet expects a HAR *request* object, not a full - // HAR entry. Extract and simplify the request, matching - // the pattern used by generateCodeSnippet() on the main - // thread (see model/ui/export.ts). - const harRequest = entry.request; - - // Sanitize postData: httpsnippet's HAR validator rejects - // null values in postData.text (e.g. CDN beacon POSTs). - // Also strip empty queryString entries that fail validation. - const postData = harRequest.postData - ? { - ...harRequest.postData, - text: harRequest.postData.text ?? '' - } - : harRequest.postData; - - const snippetInput = { - ...harRequest, - headers: (harRequest.headers || []).filter((h: any) => - h.name.toLowerCase() !== 'content-length' && - h.name.toLowerCase() !== 'content-encoding' && - !h.name.startsWith(':') - ), - queryString: (harRequest.queryString || []).filter( - (q: any) => q.name !== '' || q.value !== '' - ), - cookies: [], // Included in headers already - ...(postData !== undefined ? { postData } : {}) - }; - const snippet = new HTTPSnippet(snippetInput); - const code = snippet.convert(format.target, format.client); - if (code) { - const filename = buildZipFileName( - i + 1, - entry.request?.method ?? 'UNKNOWN', - entry.response?.status ?? null, - format.extension, - entry.request?.url - ); - const content = Array.isArray(code) ? code[0] : code; - files[`${format.folderName}/${filename}`] = encoder.encode( - typeof content === 'string' ? content : String(content) - ); - } - } catch (snippetErr) { - // Skip this format for this entry but continue - console.warn( - `Snippet generation failed for ${format.folderName}, entry ${i}:`, - snippetErr - ); - snippetErrors.push({ - format: format.folderName, - entryIndex: i + 1, - method: entry.request?.method ?? 'UNKNOWN', - url: entry.request?.url ?? 'unknown', - error: snippetErr instanceof Error ? snippetErr.message : String(snippetErr) - }); - } - - // Report progress every 5% (avoids flooding main thread) - completedSteps++; - if (totalSteps > 0) { - const currentPercent = Math.floor((completedSteps / totalSteps) * 100); - if (currentPercent >= lastReportedPercent + 5) { - lastReportedPercent = currentPercent; - ctx.postMessage({ - id, - type: 'generateZipProgress', - phase: 'snippets', - completed: completedSteps, - total: totalSteps, - percent: currentPercent - }); - } - } - } - } - - // 2. Add full traffic capture as HAR - // Contains the complete network traffic (requests + responses - // with headers, bodies, timings, cookies) for every exchange. - // The snippets in the format folders only reproduce the request; - // this file is the authoritative record of what actually happened. - const harDocument = { - log: { - version: '1.2', - creator: { - name: 'HTTP Toolkit', - version: metadata.httptoolkitVersion - }, - entries: harEntries - } - }; - const harFileName = `HTTPToolkit_${harEntries.length}-requests_full-traffic.har`; - files[harFileName] = encoder.encode( - JSON.stringify(harDocument, null, 2) - ); - - // 3. Add _metadata.json (include error summary if any) - const metadataWithErrors = snippetErrors.length > 0 - ? { ...metadata, snippetErrors: snippetErrors.length, totalSnippets: totalSteps } - : metadata; - files['_metadata.json'] = encoder.encode( - JSON.stringify(metadataWithErrors, null, 2) - ); - - // 3b. If any snippets failed, include a detailed error log - if (snippetErrors.length > 0) { - files['_errors.json'] = encoder.encode( - JSON.stringify({ - summary: `${snippetErrors.length} of ${totalSteps} snippet generations failed`, - errors: snippetErrors - }, null, 2) - ); - } - - // 4. Compress with fflate (async callback API) - zip(files, { level: 6 }, (err, data) => { - if (err) { - ctx.postMessage({ - id, - error: serializeError(err) - }); - return; - } - // Transfer the ArrayBuffer for zero-copy. - // Include snippet error count so the UI can warn if needed. - ctx.postMessage( - { - id, - buffer: data.buffer, - snippetErrors: snippetErrors.length, - totalSnippets: totalSteps - }, - [data.buffer] - ); - }); - } catch (err) { - ctx.postMessage({ - id, - error: serializeError(err) - }); - } - // Note: response is sent asynchronously from the zip() callback above - return; - } + case 'zip-export': + await handleZipExport(event.data); + break; - default: - console.error('Unknown worker event', event); - } - } catch (e) { - ctx.postMessage({ - id: event.data.id, - error: serializeError(e) - }); - } -}); \ No newline at end of file + case 'zip-export-prewarm': + prewarmZipExportPath(); diff --git a/src/util/export-filenames.ts b/src/util/export-filenames.ts index 292930a47b7a97adbed79cd79ca0e67edeb628e8..26f6e9d9757ff63dee4183399e74b5a39e088e07 100644 GIT binary patch literal 6572 zcmd^E>2e#n5zcQtMK8;>97^O+^06DsE4yS_ij`QDBFgb*EiVZUK@zLEGK0}!nN@j+ zJYk+BUjvwfw7kjxu*w37K_BSj>+W%Xb8~|>X((c))n^sYsZX(p_{&0usmyYkB^mv4 zd`9v%O){PPIB!UBCX|#XMHWa(W~8%1FS?X3L?)*+xuf^TUHXt@S2F98h^M4wZprCl zA!(k7kW@~B(okp!iF&atbSA{@7b4GB^+`>Y6MzUzb&^(am#6?%c^smO#* zpsF|SkGnL!9Cuj5#-SegUgI(lnag=3W6g(spHKTMr$4B8n%v~QOtJ>JNPzo@K(i&h z@lP*zTVGynj%kZ^)L9ZXo;QVl#Rf)|;Bas{=+fCa{_Ma1xl4oh@49qwda~pD&%5;Q z?1H~IK13=G!eYw42t+P>3Py=@_z6b27MT)!3pVVHf*Itk=BKb6tew-13`4Be4n!Ij zzgZ}_AQge^PZM3UaJrJ3#|f@YUJEJ>sMV#GC~ke<=WlTllCN)dt9i>%3RbTV>#C6T ztVIx_22z{?Jql$!hn9gzQy5if+IhU)upN2zWm7RVmxd?gEsNtz3=*2kRAMU*?kI_| zF_pA`cAV3{|M@RON1Vf6=AIwq#Km1{2#(qQ#s-I#rVq!1(fQ%<@ceIwZ$<}i_s@s4 zNBxiN+WzGwyn9(}YQw|4z{kA6 z%k6IY{A7o>;Pc0Bed5-qZhhv~AKdz*TmR_R=Or%=7v95acwsfXuo_-i4KJ*Q7goay ztKo(9d3)nir@Y_E{?8-6*67{g;7=ECaXI+t#l}W|1tSn3f^wVuF8@qIF>FZVix|Nb z6`@ush;dJLV82g0KAlTo5>Ra& zUpUzw00u)D0;Y@!_6Yp_%(C#ljy8B~)P~VZd5?X%$W(+3`F1j~V9q8wIzAs>z(`hA z{9R98Bzn%NE@MiNE}WDzSPh_{eCbn}+6@kkBuChi-y*34pMc_kbSX5aYmVf*R`4PGLq0%p(hz=R-|oFFL}#+0U6mP96g8mV9-r*_Oyk{xg;!pf|~e~Tku zC+`v1*#QjZIWIFpJ&+^|ksGs>5;`5Sh{ln@Hh{?S=+QlkT|T;pQI})u`elVQ&wM&a z!ce3@h$Ig0YLu?)woh-8VuBmjhJe|TkXJI*4O6jSVg_e`?;W~8W0{@Z9g2{uj5@8# z_}oEJ?{#+~2E(aU_xi-4i@C2!}S4g985Qh1hUzBMk6-8HRT>kNSnTVahBzu{`zm^<+qE- zk3PM#9S9Tcx=F@w6O2m!I!kw zbTp&drWLDs#-_^=;f>3%qg=|Aoa>|i6MrUinbV-GV02wG|2Jsi8T4mL{w!exLlW16CR!6qJtZxt00J?>O6A;NSaCQ_?_2Utwu z46rI@Hr(6+b7L{*2^FfL=gBcxWl5Ysf}uz<)HImc3{qpdSSW-4Kd32+62Jg4;z#1vd)%d=nPa?a3oRV$i{=2KffpS+osL6@l}1oaZ$SxO zj^5Ci%XNH68lp~UuM~;4b-OB$JgRV{f|ffy&J*4;(@wpmv%3be*&7rf;feSC`8#7% zA0}yluwz>I{DpJKGG_sfc>&e*qJFe=Rr$U`ku}8{wB~{kXqmdqE-kAULpy0gLl#j3 z$YtMj2bj713frGnAw&IOLe1sYh*%TCeYb zlzf`S*Sjw<=WlPX?x9>EVH;|=7^|pi6&urnm5=Ec7WinxvrE>RcrR$cz^TVSX0eFq~=-x_PjvPjf;o`5Lo~tLC6m(Li=Rz*B zn<$SJ`f&94jHYN%67+syov#RO+9f%y-EB~U>Go*`Wd~M}0VwN~(yHKu+U+-4eEe~%z40}%dz3S3O z4#2%y9`MOl*41Ix_~QY>QX!BzZ?DopHN;?P2LM1UGj0k zhOn|qF8lZfy_bG9T-o5FH*zNMwrjO-;NTHiu;8~0sOmDD`QI#7`}SO=CCctDz2Tw2 zf-QgbIuF|x_>NOvT$Z2??Y%4u0#c}|11K}L0Ic9dl!6#nghYQX^%1b)?^0x5XW4sn z4_ePXIKjK_1;&Rh)S|UVPC1Tv_t`RYXoLDK{)-^T1=c7}R9xV#3_oy_YH4$B9xi|a dS4xi#&Hp-JumyfZ>C*qKQk-6H{Nexa^B;uPwi5sV literal 3825 zcma)9|8mnt5dPm!aip2C9BfGrOiN8uPYMoUN&-Wip(O$3BAu-RSyFZ9#NhbfK0qI_ zPttGiBv}s0l#mJ1-R<7~`1aeCx3kk?JM2`;L~1D%n`8x>ic}O_%XG>VpNK9Cvs6i? zMXK3ECL-mt@}yYgS)r8+9i--%&qxTzuW(jE3i^^HyJm;u@tbj$C7-2kD<+iYmx)xd zh*-|G7DcKCq=2y=JKTTE6x1YQz}VLEXf!^2{d~oiai%ogymDh+Y&B0`avjDv%(fcW z1lG1-8F4LE!7|L|sb2L8@z)&Js|_XZU%p|y2xEDLJ2#ZvC;e6CwFxJw%yXg105Vvn zkyzlu((qt;GTtAbo?vC|!BQ;riWGRQ76n(cqy0aEgO{&H$NNX40RO!hAF@5x_x-1> zmS>Z6!c*kvD?-ni(gj7DvPCIkb0wHe*V&p>~oWi!5bCDH*)=d(*uz|zNlr`l!97Db{E zdS0XqV&;{W+(P6D4~64>jJ3|yaB%LOdtR5d+a22XJZp1p@*R?A!yi7lJKJj^5?o6f z7rjZrr!zqC4*7HM;celjye>FOfsmhY;8We9;p~@R2V36x`OR?WeBt}O^F{yQgExg2 z0o;HOZ>^VgY!6OMc<<5`JgfzcANCJ)JVV{^99f;s6D{)uDF&_z6=p@#D3wTr)2|ZJ zdD>dlco{9=OsEZ9Vw7nYDd7?QqnI3mOPqO;)R4U~ z;ho47jC2E3C&_I%!)xaOL^>6)+wG?Y92zBM5l=?gYR-9~plOe#;+ld>cOf^GmB{ZY zQd>vRxt2Q4=7=-W)G<|UQ$?)&r4}nI$|`qj>6;tWk5#^6A#q{D9$ZC9rIZd>={-}~ zOz4n zz`nEo2R2w6-N5W`bD2bh*s9N%V1VynByg)N)uOVLjD&$ z-iEI4_k$Osaj@(A0ReN0OwBI|y{?KYX!^U4f;X>E=u|%-uwTm5&5Eg;f2oi7KLyW6 zuSVlhkc%{eJ3*3#JfSSQpA;RV>&J+Ho!BY*kupwFl`uJ*iAbWG5y&)h$@nFK<~o*0 zdD+-ViQssr0Aeu*U4RI|_9pU_^G1`TUc5kOH_Pa|wr7%#vMsZ0a(@q|czZ1?!|D>d zq!hlF`2h^TKp-50BGVPCe$h2cguyJTO3VohwVxZoy%Jv}fo#w)@#-fN@x@ zv~Cw|RgKX*8G*s}*dtpfcAYxsOX}9xpkA3yog)w(u)T2lU1pvqNtP9kvA)ykxH*qb zG%vJ+2cr|L@3%|1ueBZ-*Pzz1PBN;|)8pTdU%xwUcU+yFVqz$saV4D2x+$K0-0!`& zodk`fDRSFt*nluWzl|4r0Hy(Fv0>$hUGGqpm2fK=Vr}CH0NNu;uu*NA!Fyj}iQoHq z!x+8LP-0SNwGdFPiYBLi6O2Q9%$y!llVlIqQw6Pd*(2YWx~ZOqJ*dCfUe<|P1@^_p zw|cYDSlKPQZROf$xVX9~O$HH;QZ+9GyA~|U(zb^0p#V!$rKm3$Z)Yr#GpTK#E_sWL za53^u0iR#*n$l{H9Dcv0P4jAbfF5@>8+b{E#L4A!x=D+)5oOnDlJN+BfC=wpz(&2_ zI}Kx=ZPK5aWj6-lU4Qq-p8puXLH}{@kstJ*`0HW19EXWG4$Q!>ZnF#kSxx+ATsLDr z946)|xyF0}Y+U+%BmDgov~OqnV`3pBJLc(BJfrUbFovC6?m|-FrQp+z?Oz28sV?_s*WLQ q2bH~%qV=a5fLZ?7nZdl4<$2Kr_jdWUw!NeXfDdW|RxSaYYU@9*9I(d# diff --git a/test/unit/model/ui/snippet-export-sanitization.spec.ts b/test/unit/model/ui/snippet-export-sanitization.spec.ts new file mode 100755 index 00000000..31c0996e --- /dev/null +++ b/test/unit/model/ui/snippet-export-sanitization.spec.ts @@ -0,0 +1,46 @@ +import { expect } from '../../../test-setup'; + +import { simplifyHarEntryRequestForSnippetExport } from '../../../../src/model/ui/snippet-export-sanitization'; + +describe('snippet-export-sanitization', () => { + it('drops hop-by-hop headers, strips empty query params and preserves raw postData text', () => { + const sanitized = simplifyHarEntryRequestForSnippetExport({ + method: 'POST', + url: 'https://example.com/path', + httpVersion: 'HTTP/1.1', + headers: [ + { name: 'Connection', value: 'keep-alive, x-transient' }, + { name: 'Keep-Alive', value: 'timeout=5' }, + { name: 'Transfer-Encoding', value: 'chunked' }, + { name: 'X-Transient', value: 'drop-me' }, + { name: 'X-Keep-Me', value: 'keep-me' } + ], + queryString: [ + { name: '', value: '' }, + { name: 'keep', value: '1' } + ], + cookies: [ + { name: 'session', value: 'secret' } + ], + headersSize: -1, + bodySize: 7, + postData: { + mimeType: 'application/x-www-form-urlencoded', + params: [{ name: 'keep', value: '1' }], + text: 'keep=1' + } + } as any); + + expect(sanitized.headers).to.deep.equal([ + { name: 'X-Keep-Me', value: 'keep-me' } + ]); + expect(sanitized.queryString).to.deep.equal([ + { name: 'keep', value: '1' } + ]); + expect(sanitized.cookies).to.deep.equal([]); + expect(sanitized.postData).to.deep.include({ + mimeType: 'application/x-www-form-urlencoded', + text: 'keep=1' + }); + }); +}); diff --git a/test/unit/model/ui/zip-export-service.spec.ts b/test/unit/model/ui/zip-export-service.spec.ts new file mode 100755 index 00000000..a112adc3 --- /dev/null +++ b/test/unit/model/ui/zip-export-service.spec.ts @@ -0,0 +1,134 @@ +import { expect } from '../../../test-setup'; +import * as sinon from 'sinon'; + +import { ZipExportController } from '../../../../src/model/ui/zip-export-service'; + +function getDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: any) => void; + + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, resolve, reject }; +} + +function makeHar() { + return { + log: { + version: '1.2', + creator: { name: 'httptoolkit-ui', version: 'test' }, + entries: [{}], + _tlsErrors: [] + } + } as any; +} + +describe('ZipExportController', () => { + let generateHarStub: sinon.SinonStub; + let exportAsZipStub: sinon.SinonStub; + + beforeEach(() => { + generateHarStub = sinon.stub().resolves(makeHar()); + exportAsZipStub = sinon.stub(); + + if (!window.URL.createObjectURL) { + (window.URL as any).createObjectURL = () => 'blob:test'; + } + if (!window.URL.revokeObjectURL) { + (window.URL as any).revokeObjectURL = () => {}; + } + + sinon.stub(window.URL, 'createObjectURL').returns('blob:test'); + sinon.stub(window.URL, 'revokeObjectURL').callsFake(() => {}); + sinon.stub(HTMLAnchorElement.prototype, 'click').callsFake(() => {}); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('ignores stale successful completions from a previous run after retry', async () => { + const firstExport = getDeferred(); + const secondExport = getDeferred(); + exportAsZipStub.onFirstCall().returns(firstExport.promise); + exportAsZipStub.returns(secondExport.promise); + + const controller = new ZipExportController({ + generateHar: generateHarStub as any, + exportAsZip: exportAsZipStub as any + }); + + const firstRun = controller.run({ + events: [] as any, + formatIds: ['shell~~curl'] + }); + await Promise.resolve(); + + const secondRun = controller.run({ + events: [] as any, + formatIds: ['shell~~curl'] + }); + await Promise.resolve(); + + firstExport.resolve({ + archive: new ArrayBuffer(1), + cancelled: false, + snippetSuccessCount: 1, + snippetErrorCount: 0 + }); + await firstRun; + + expect((window.URL.createObjectURL as sinon.SinonStub).called).to.equal(false); + expect(controller.state.kind).to.equal('running'); + + secondExport.resolve({ + archive: new ArrayBuffer(2), + cancelled: false, + snippetSuccessCount: 2, + snippetErrorCount: 0 + }); + await secondRun; + + expect(exportAsZipStub.callCount).to.equal(2); + expect((window.URL.createObjectURL as sinon.SinonStub).calledOnce).to.equal(true); + expect(controller.state.kind).to.equal('done'); + if (controller.state.kind === 'done') { + expect(controller.state.snippetSuccessCount).to.equal(2); + expect(controller.state.snippetErrorCount).to.equal(0); + expect(controller.state.downloadUrl).to.equal('blob:test'); + expect(controller.state.downloadName).to.match(/\.zip$/); + expect(controller.state.downloadBytes).to.equal(2); + expect(controller.state.autoDownloadAttempted).to.equal(true); + } + }); + + it('reset invalidates an in-flight run so later completion cannot mutate state', async () => { + const exportDeferred = getDeferred(); + exportAsZipStub.returns(exportDeferred.promise); + + const controller = new ZipExportController({ + generateHar: generateHarStub as any, + exportAsZip: exportAsZipStub as any + }); + const runPromise = controller.run({ + events: [] as any, + formatIds: ['shell~~curl'] + }); + await Promise.resolve(); + + controller.reset(); + expect(controller.state.kind).to.equal('idle'); + + exportDeferred.resolve({ + archive: new ArrayBuffer(1), + cancelled: false, + snippetSuccessCount: 1, + snippetErrorCount: 0 + }); + await runPromise; + + expect(controller.state.kind).to.equal('idle'); + expect((window.URL.createObjectU \ No newline at end of file diff --git a/test/unit/workers/zip-export.spec.ts b/test/unit/workers/zip-export.spec.ts new file mode 100755 index 00000000..985f662e --- /dev/null +++ b/test/unit/workers/zip-export.spec.ts @@ -0,0 +1,341 @@ +import { expect } from '../../test-setup'; +import { unzipSync, strFromU8 } from 'fflate'; + +import { exportAsZip } from '../../../src/services/ui-worker-api'; + +describe('ZIP export worker round-trip', function () { + this.timeout(10000); + + const makeHar = (entryCount: number) => ({ + log: { + version: '1.2', + creator: { name: 'httptoolkit-ui', version: 'test' }, + entries: Array.from({ length: entryCount }).map((_, i) => ({ + startedDateTime: new Date().toISOString(), + time: 0, + request: { + method: 'GET', + url: `https://example.com/item/${i}`, + httpVersion: 'HTTP/1.1', + headers: [ + { name: 'Host', value: 'example.com' }, + { name: 'Content-Length', value: '0' }, + { name: ':authority', value: 'example.com' } + ], + queryString: [], + cookies: [], + headersSize: -1, + bodySize: 0 + }, + response: { + status: 200, + statusText: 'OK', + httpVersion: 'HTTP/1.1', + headers: [], + cookies: [], + content: { size: 0, mimeType: 'text/plain' }, + redirectURL: '', + headersSize: -1, + bodySize: 0 + }, + cache: {}, + timings: { send: 0, wait: 0, receive: 0 } + })), + _tlsErrors: [] + } + }) as any; + + const curlFormat = { + id: 'shell~~curl', + target: 'shell', + client: 'curl', + category: 'Shell', + label: 'cURL', + folderName: 'shell-curl', + extension: 'sh' + }; + + it('produces a valid ZIP with snippets, HAR and manifest', async () => { + const res = await exportAsZip({ + har: makeHar(2), + formats: [curlFormat], + toolVersion: 'test' + }); + + expect(res.cancelled).to.equal(false); + expect(res.snippetErrorCount).to.equal(0); + expect(res.snippetSuccessCount).to.equal(2); + + const unpacked = unzipSync(new Uint8Array(res.archive)); + const names = Object.keys(unpacked); + expect(names).to.include('manifest.json'); + expect(names).to.include('requests.har'); + expect(names.filter(n => n.startsWith('shell-curl/') && !n.endsWith('/'))).to.have.length(2); + + const manifest = JSON.parse(strFromU8(unpacked['manifest.json'])); + expect(manifest.version).to.equal(1); + expect(manifest.requestCount).to.equal(2); + expect(manifest.formats).to.have.length(1); + expect(manifest.errors).to.have.length(0); + }); + + it('can be cancelled mid-flight', async () => { + const controller = new AbortController(); + const p = exportAsZip({ + har: makeHar(200), + formats: [curlFormat], + toolVersion: 'test', + signal: controller.signal + }); + setTimeout(() => controller.abort(), 5); + const res = await p; + expect(res.cancelled).to.equal(true); + }); + + it('filters content-length / pseudo-headers before snippet generation', async () => { + const res = await exportAsZip({ + har: makeHar(1), + formats: [curlFormat], + toolVersion: 'test' + }); + const unpacked = unzipSync(new Uint8Array(res.archive)); + const curlFile = Object.keys(unpacked).find(n => n.startsWith('shell-curl/') && !n.endsWith('/'))!; + const content = strFromU8(unpacked[curlFile]); + expect(content.toLowerCase()).to.not.include('content-length'); + expect(content).to.not.include(':authority'); + }); + + it('surfaces per-snippet errors as partial failures, not overall rejection', async () => { + const bogusFormat = { + id: 'nonsense~~nonsense', + target: 'nonsense', + client: 'nonsense', + category: 'Nonsense', + label: 'Nonsense', + folderName: 'nonsense', + extension: 'txt' + }; + + const res = await exportAsZip({ + har: makeHar(1), + formats: [bogusFormat], + toolVersion: 'test' + }); + expect(res.snippetSuccessCount).to.equal(0); + expect(res.snippetErrorCount).to.equal(1); + const unpacked = unzipSync(new Uint8Array(res.archive)); + const manifest = JSON.parse(strFromU8(unpacked['manifest.json'])); + expect(manifest.errors).to.have.length(1); + expect(manifest.errors[0].formatId).to.equal('nonsense~~nonsense'); + }); + + it('rejects empty request sets instead of producing an empty archive', async () => { + await expect(exportAsZip({ + har: makeHar(0), + formats: [curlFormat], + toolVersion: 'test' + })).to.be.rejectedWith('No HTTP requests available for ZIP export'); + }); + + it('rejects empty format selections instead of producing an empty archive', async () => { + await expect(exportAsZip({ + har: makeHar(1), + formats: [], + toolVersion: 'test' + })).to.be.rejectedWith('No formats selected for ZIP export'); + }); + + it('error records carry full request context (entryIndex, method, url, status)', async () => { + const bogusFormat = { + id: 'nonsense~~nonsense', + target: 'nonsense', + client: 'nonsense', + category: 'Nonsense', + label: 'Bogus Format', + folderName: 'bogus', + extension: 'txt' + }; + const res = await exportAsZip({ + har: makeHar(3), + formats: [bogusFormat], + toolVersion: 'test' + }); + const unpacked = unzipSync(new Uint8Array(res.archive)); + const manifest = JSON.parse(strFromU8(unpacked['manifest.json'])); + expect(manifest.errors).to.have.length(3); + for (let i = 0; i < 3; i++) { + const e = manifest.errors[i]; + expect(e.entryIndex).to.equal(i); + expect(e.method).to.equal('GET'); + expect(e.url).to.include('example.com/item/'); + expect(e.status).to.equal(200); + expect(e.format).to.equal('Bogus Format'); + expect(e.formatId).to.equal('nonsense~~nonsense'); + } + expect(Object.keys(unpacked)).to.include('_errors.json'); + const standalone = JSON.parse(strFromU8(unpacked['_errors.json'])); + expect(standalone.errors).to.have.length(3); + }); + + it('produces filenames that embed the response status code', async () => { + const res = await exportAsZip({ + har: makeHar(2), + formats: [curlFormat], + toolVersion: 'test' + }); + const unpacked = unzipSync(new Uint8Array(res.archive)); + const files = Object.keys(unpacked).filter(n => n.startsWith('shell-curl/') && !n.endsWith('/')); + for (const f of files) { + expect(f).to.match(/_200_/); + } + }); + + it('truncates large request bodies with a pointer to requests.har', async () => { + const largeBody = 'A'.repeat(2000); + const harWithBody: any = makeHar(1); + harWithBody.log.entries[0].request.postData = { + mimeType: 'text/plain', + text: largeBody + }; + + const res = await exportAsZip({ + har: harWithBody, + formats: [curlFormat], + toolVersion: 'test', + snippetBodySizeLimit: 100 + }); + const unpacked = unzipSync(new Uint8Array(res.archive)); + const curlFile = Object.keys(unpacked).find(n => n.startsWith('shell-curl/') && !n.endsWith('/'))!; + const content = strFromU8(unpacked[curlFile]); + expect(content).to.include('REQUEST BODY TRUNCATED'); + expect(content).to.include('requests.har'); + expect(content).to.not.include('A'.repeat(500)); + const harInZip = JSON.parse(strFromU8(unpacked['requests.har'])); + expect(harInZip.log.entries[0].request.postData.text).to.equal(largeBody); + }); + + it('passes bodies through when snippetBodySizeLimit is not set', async () => { + const body = 'B'.repeat(500); + const harWithBody: any = makeHar(1); + harWithBody.log.entries[0].request.postData = { + mimeType: 'text/plain', + text: body + }; + const res = await exportAsZip({ + har: harWithBody, + formats: [curlFormat], + toolVersion: 'test' + }); + const unpacked = unzipSync(new Uint8Array(res.archive)); + const curlFile = Object.keys(unpacked).find(n => n.startsWith('shell-curl/') && !n.endsWith('/'))!; + const content = strFromU8(unpacked[curlFile]); + expect(content).to.not.include('REQUEST BODY TRUNCATED'); + }); + + it('preserves raw postData.text for form-encoded requests', async () => { + const harWithFormBody: any = makeHar(1); + harWithFormBody.log.entries[0].request.method = 'POST'; + harWithFormBody.log.entries[0].request.postData = { + mimeType: 'application/x-www-form-urlencoded', + params: [{ name: 'key', value: 'value' }, { name: 'msg', value: 'hello' }], + text: 'key=value&msg=hello' + }; + + const res = await exportAsZip({ + har: harWithFormBody, + formats: [curlFormat], + toolVersion: 'test' + }); + expect(res.snippetSuccessCount).to.equal(1); + expect(res.snippetErrorCount).to.equal(0); + + const unpacked = unzipSync(new Uint8Array(res.archive)); + const curlFile = Object.keys(unpacked).find(n => n.startsWith('shell-curl/') && !n.endsWith('/'))!; + const content = strFromU8(unpacked[curlFile]); + // The raw text body (not the params array) should drive snippet generation + expect(content).to.include('key=value'); + }); + + it('recovers clj-http crashes on nested-null JSON bodies via ultra-safe retry', async () => { + // Repro: concrete GraphQL body as observed in mydealz / recombee — + // `variables: null` or `persistedQuery: null` triggers a + // "Cannot read properties of null (reading 'constructor')" in + // clj-http's `jsType(null).constructor.name`. The two-stage + // retry (reduced -> ultra-safe) must still produce the snippet. + const graphqlBody = JSON.stringify({ + operationName: 'ThreadList', + query: 'query ThreadList { threads { id } }', + variables: null, + extensions: { persistedQuery: null } + }); + const harWithNullBody: any = makeHar(1); + harWithNullBody.log.entries[0].request.method = 'POST'; + harWithNullBody.log.entries[0].request.postData = { + mimeType: 'application/json', + text: graphqlBody + }; + + const cljFormat = { + id: 'clojure~~clj_http', + target: 'clojure', + client: 'clj_http', + category: 'Clojure', + label: 'clj-http', + folderName: 'clojure-clj_http', + extension: 'clj' + }; + + const res = await exportAsZip({ + har: harWithNullBody, + formats: [cljFormat], + toolVersion: 'test' + }); + + // Success, not an error in the manifest. + expect(res.snippetSuccessCount).to.equal(1); + expect(res.snippetErrorCount).to.equal(0); + const unpacked = unzipSync(new Uint8Array(res.archive)); + const manifest = JSON.parse(strFromU8(unpacked['manifest.json'])); + expect(manifest.errors).to.have.length(0); + // File was actually written and contains the clj-http client + // require (smoke test that it is not an empty file). + const cljFile = Object.keys(unpacked).find(n => + n.startsWith('clojure-clj_http/') && !n.endsWith('/') + )!; + expect(cljFile).to.match(/_200_/); + const content = strFromU8(unpacked[cljFile]); + expect(content).to.include('clj-http.client'); + }); + + it('recovers clj-http crashes on JSON body that parses to top-level null', async () => { + // Variant: `JSON.parse(text) === null` directly. Leads to + // `params[form-params] = null` in clj-http and crashes the + // `filterEmpty` pass. Must be caught by ultra-safe retry. + const harWithTopLevelNull: any = makeHar(1); + harWithTopLevelNull.log.entries[0].request.method = 'POST'; + harWithTopLevelNull.log.entries[0].request.postData = { + mimeType: 'application/json', + text: 'null' + }; + + const cljFormat = { + id: 'clojure~~clj_http', + target: 'clojure', + client: 'clj_http', + category: 'Clojure', + label: 'clj-http', + folderName: 'clojure-clj_http', + extension: 'clj' + }; + + const res = await exportAsZip({ + har: harWithTopLevelNull, + formats: [cljFormat], + toolVersion: 'test' + }); + + expect(res.snippetSuccessCount).to.equal(1); + expect(res.snippetErrorCount).to.equal(0); + }); +}); + \ No newline at end of file From 9210cb97679933b248f55a84ceac0972b3bb1ea1 Mon Sep 17 00:00:00 2001 From: stephanbuettig Date: Thu, 23 Apr 2026 19:34:15 +0000 Subject: [PATCH 3/5] fix: strip null bytes from copied files, add missing package-lock.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several source files contained trailing NULL bytes introduced by the cross-filesystem copy (Windows CIFS mount → Linux). This caused export-filenames.ts and others to be detected as binary, breaking the build. Affected files cleaned: export-filenames.ts, zip-export-formats.ts, export.ts, http-export-card.tsx, zip-export.spec.ts, ui-store.ts. Also adds the missing package-lock.json update for the fflate dependency added in package.json. --- package-lock.json | 27 ++++++++++++++---- src/components/view/http/http-export-card.tsx | 1 - src/model/ui/export.ts | Bin 4797 -> 4714 bytes src/model/ui/ui-store.ts | 1 - src/model/ui/zip-export-formats.ts | 1 - src/util/export-filenames.ts | Bin 6572 -> 6329 bytes test/unit/workers/zip-export.spec.ts | 1 - 7 files changed, 21 insertions(+), 10 deletions(-) mode change 100755 => 100644 src/model/ui/zip-export-formats.ts mode change 100755 => 100644 test/unit/workers/zip-export.spec.ts diff --git a/package-lock.json b/package-lock.json index e6bd91d3..9090af0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,6 +66,7 @@ "dompurify": "^3.4.0", "fast-json-patch": "^3.1.1", "fast-xml-parser": "^5.5.7", + "fflate": "^0.8.2", "graphql": "^15.8.0", "har-validator": "^5.1.3", "http-encoding": "^2.0.1", @@ -9630,9 +9631,10 @@ } }, "node_modules/fflate": { - "version": "0.4.8", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz", - "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==" + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" }, "node_modules/file-size": { "version": "0.0.5", @@ -16071,6 +16073,12 @@ "rrweb-snapshot": "^1.1.14" } }, + "node_modules/posthog-js/node_modules/fflate": { + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz", + "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==", + "license": "MIT" + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -29770,9 +29778,9 @@ } }, "fflate": { - "version": "0.4.8", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz", - "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==" + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==" }, "file-size": { "version": "0.0.5", @@ -34442,6 +34450,13 @@ "requires": { "fflate": "^0.4.1", "rrweb-snapshot": "^1.1.14" + }, + "dependencies": { + "fflate": { + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz", + "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==" + } } }, "prebuild-install": { diff --git a/src/components/view/http/http-export-card.tsx b/src/components/view/http/http-export-card.tsx index f842718d..21c8bd64 100644 --- a/src/components/view/http/http-export-card.tsx +++ b/src/components/view/http/http-export-card.tsx @@ -241,4 +241,3 @@ export class HttpExportCard extends React.Component { this.props.uiStore!.exportSnippetFormat = optionKey; } }; - \ No newline at end of file diff --git a/src/model/ui/export.ts b/src/model/ui/export.ts index ea72b213581f3a92674a1160b7e472dff93e424d..9d1e8f7afae6dada7d66714ca3e846feabf3c335 100644 GIT binary patch delta 7 Ocmdn1`buR(mJk3AC<7e; delta 91 ScmaE*vR8FOmQXMQ^#K4cPXllO diff --git a/src/model/ui/ui-store.ts b/src/model/ui/ui-store.ts index 3429cc40..6e173f96 100644 --- a/src/model/ui/ui-store.ts +++ b/src/model/ui/ui-store.ts @@ -598,4 +598,3 @@ export class UiStore { } } - \ No newline at end of file diff --git a/src/model/ui/zip-export-formats.ts b/src/model/ui/zip-export-formats.ts old mode 100755 new mode 100644 index 281d3e89..9713fc86 --- a/src/model/ui/zip-export-formats.ts +++ b/src/model/ui/zip-export-formats.ts @@ -197,4 +197,3 @@ export function sanitizeFormatIds(ids: Iterable): string[] { } return out; } - \ No newline at end of file diff --git a/src/util/export-filenames.ts b/src/util/export-filenames.ts index 26f6e9d9757ff63dee4183399e74b5a39e088e07..1227751dcc41f93470faac1688d726a8f838d3c0 100644 GIT binary patch delta 7 OcmZ2uywh;QP6+@F=mSdt delta 252 WcmdmKxW;(HPKkPkfdwT4VLbrjfdoDP diff --git a/test/unit/workers/zip-export.spec.ts b/test/unit/workers/zip-export.spec.ts old mode 100755 new mode 100644 index 985f662e..e24d6579 --- a/test/unit/workers/zip-export.spec.ts +++ b/test/unit/workers/zip-export.spec.ts @@ -338,4 +338,3 @@ describe('ZIP export worker round-trip', function () { expect(res.snippetErrorCount).to.equal(0); }); }); - \ No newline at end of file From ebd2a2f299b5dc1961c7723b6035a16ac4fbd846 Mon Sep 17 00:00:00 2001 From: stephanbuettig Date: Thu, 23 Apr 2026 19:41:41 +0000 Subject: [PATCH 4/5] fix: restore truncated files (null-byte removal cut content) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous null-byte cleanup (9210cb9) used tr -d which stripped null bytes but also truncated content that followed embedded nulls. 6 files were affected and are now restored from the verified local copy (808 tests passing): - ui-worker-api.ts: 288→321 lines (exportAsZip body was missing) - ui-worker.ts: 845→858 lines - zip-export-dialog.tsx: 851→864 lines - zip-export-service.ts: 371→393 lines - zip-manifest.ts: 77→79 lines - zip-export-service.spec.ts: 133→136 lines --- src/components/view/zip-export-dialog.tsx | 15 +++++++- src/model/ui/zip-export-service.ts | 23 ++++++++++++- src/model/ui/zip-manifest.ts | 3 +- src/services/ui-worker-api.ts | 34 ++++++++++++++++++- src/services/ui-worker.ts | 13 +++++++ test/unit/model/ui/zip-export-service.spec.ts | 4 ++- 6 files changed, 87 insertions(+), 5 deletions(-) mode change 100755 => 100644 src/components/view/zip-export-dialog.tsx mode change 100755 => 100644 src/model/ui/zip-export-service.ts mode change 100755 => 100644 src/model/ui/zip-manifest.ts mode change 100755 => 100644 test/unit/model/ui/zip-export-service.spec.ts diff --git a/src/components/view/zip-export-dialog.tsx b/src/components/view/zip-export-dialog.tsx old mode 100755 new mode 100644 index cfb00181..d8eea326 --- a/src/components/view/zip-export-dialog.tsx +++ b/src/components/view/zip-export-dialog.tsx @@ -849,4 +849,17 @@ export class ZipExportDialog extends React.Component { : + + Download ZIP + + } +
+
+
+
; + } +} + \ No newline at end of file diff --git a/src/model/ui/zip-export-service.ts b/src/model/ui/zip-export-service.ts old mode 100755 new mode 100644 index 6a083ebe..792e95d4 --- a/src/model/ui/zip-export-service.ts +++ b/src/model/ui/zip-export-service.ts @@ -369,4 +369,25 @@ export class ZipExportController { runInAction(() => { if (!this.isCurrentRun(runId, runAbortController)) return; this.state = { - \ No newline at end of file + kind: 'error', + message: e && e.message + ? String(e.message) + : 'Unknown error during ZIP export.' + }; + }); + } finally { + if (this.isCurrentRun(runId, runAbortController)) { + this.abortController = undefined; + zipLog('run() finally: abortController cleaned up'); + } + } + } + + @action.bound + reset() { + this.invalidateActiveRun(); + this.abortActiveRun(); + this.revokeActiveDownloadUrl(); + this.state = { kind: 'idle' }; + } +} diff --git a/src/model/ui/zip-manifest.ts b/src/model/ui/zip-manifest.ts old mode 100755 new mode 100644 index fa4196e9..90a3a188 --- a/src/model/ui/zip-manifest.ts +++ b/src/model/ui/zip-manifest.ts @@ -75,4 +75,5 @@ export interface ZipExportManifest { */ errors: ZipExportErrorRecord[]; /** Name of the HAR file included in the archive, if the HAR was bundled separately. */ - \ No newline at end of file + harFile: string; +} diff --git a/src/services/ui-worker-api.ts b/src/services/ui-worker-api.ts index 09a4b4a2..c866de02 100644 --- a/src/services/ui-worker-api.ts +++ b/src/services/ui-worker-api.ts @@ -286,4 +286,36 @@ export async function exportAsZip(args: { snippetBodySizeLimit?: number; }): Promise { try { - \ No newline at end of file + return await callApi( + { + type: 'zip-export', + har: args.har, + formats: args.formats, + toolVersion: args.toolVersion, + snippetBodySizeLimit: args.snippetBodySizeLimit + }, + [], + { + signal: args.signal, + onProgress: args.onProgress, + cancelChannel: true, + timeoutMs: ZIP_EXPORT_TIMEOUT_MS + } + ); + } catch (error: any) { + // Keep the ZIP API contract stable: callers expect cancellation to + // resolve as a cancelled response, not reject. `callApi` may reject + // immediately on abort to avoid hangs before the worker yields. + if (error?.name === 'AbortError') { + return { + id: -1, + archive: new ArrayBuffer(0), + cancelled: true, + snippetSuccessCount: 0, + snippetErrorCount: 0 + }; + } + + throw error; + } +} diff --git a/src/services/ui-worker.ts b/src/services/ui-worker.ts index 8924aece..7e9d820e 100644 --- a/src/services/ui-worker.ts +++ b/src/services/ui-worker.ts @@ -843,3 +843,16 @@ ctx.addEventListener('message', async (event: { data: BackgroundRequest }) => { case 'zip-export-prewarm': prewarmZipExportPath(); + ctx.postMessage({ id: event.data.id, warmed: true } as ZipExportPrewarmResponse); + break; + + default: + console.error('Unknown worker event', event); + } + } catch (e) { + ctx.postMessage({ + id: event.data.id, + error: serializeError(e) + }); + } +}); diff --git a/test/unit/model/ui/zip-export-service.spec.ts b/test/unit/model/ui/zip-export-service.spec.ts old mode 100755 new mode 100644 index a112adc3..51175e49 --- a/test/unit/model/ui/zip-export-service.spec.ts +++ b/test/unit/model/ui/zip-export-service.spec.ts @@ -131,4 +131,6 @@ describe('ZipExportController', () => { await runPromise; expect(controller.state.kind).to.equal('idle'); - expect((window.URL.createObjectU \ No newline at end of file + expect((window.URL.createObjectURL as sinon.SinonStub).called).to.equal(false); + }); +}); From 7b2e958dd5c9ce4b3165d747a2d0cbd85bb708c3 Mon Sep 17 00:00:00 2001 From: stephanbuettig Date: Thu, 23 Apr 2026 20:00:04 +0000 Subject: [PATCH 5/5] chore: fix file permissions and trailing whitespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reset executable bit (100755 → 100644) on three source files that were incorrectly marked during cross-filesystem copy - Remove trailing whitespace on final line of zip-export-dialog.tsx --- src/components/view/multi-selection-summary-pane.tsx | 0 src/components/view/zip-export-dialog.tsx | 1 - src/model/ui/snippet-export-sanitization.ts | 0 test/unit/model/ui/snippet-export-sanitization.spec.ts | 0 4 files changed, 1 deletion(-) mode change 100755 => 100644 src/components/view/multi-selection-summary-pane.tsx mode change 100755 => 100644 src/model/ui/snippet-export-sanitization.ts mode change 100755 => 100644 test/unit/model/ui/snippet-export-sanitization.spec.ts diff --git a/src/components/view/multi-selection-summary-pane.tsx b/src/components/view/multi-selection-summary-pane.tsx old mode 100755 new mode 100644 diff --git a/src/components/view/zip-export-dialog.tsx b/src/components/view/zip-export-dialog.tsx index d8eea326..34266442 100644 --- a/src/components/view/zip-export-dialog.tsx +++ b/src/components/view/zip-export-dialog.tsx @@ -862,4 +862,3 @@ export class ZipExportDialog extends React.Component { ; } } - \ No newline at end of file diff --git a/src/model/ui/snippet-export-sanitization.ts b/src/model/ui/snippet-export-sanitization.ts old mode 100755 new mode 100644 diff --git a/test/unit/model/ui/snippet-export-sanitization.spec.ts b/test/unit/model/ui/snippet-export-sanitization.spec.ts old mode 100755 new mode 100644