diff --git a/package.json b/package.json index edbf0ac2f..fe83b33ee 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,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", diff --git a/src/components/view/http/http-export-card.tsx b/src/components/view/http/http-export-card.tsx index 8ae3c4f08..f842718dc 100644 --- a/src/components/view/http/http-export-card.tsx +++ b/src/components/view/http/http-export-card.tsx @@ -1,9 +1,9 @@ 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'; @@ -15,7 +15,7 @@ import { generateCodeSnippet, getCodeSnippetFormatKey, getCodeSnippetFormatName, - getCodeSnippetOptionFromKey, + getSafeCodeSnippetOptionFromKey, DEFAULT_SNIPPET_FORMAT_KEY, snippetExportOptions, SnippetOption @@ -31,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; @@ -140,63 +141,104 @@ const ExportHarPill = styled(observer((p: { @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(); - return -
- { isPaidUser - ? - : - } - - - onChange={this.setSnippetOption} - value={this.snippetOption} - optGroups={snippetExportOptions} - keyFormatter={getCodeSnippetFormatKey} - nameFormatter={getCodeSnippetFormatName} - /> - - - Export - -
- - { isPaidUser ? -
- + +
+ { 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.snippetOption} + optGroups={snippetExportOptions} + keyFormatter={getCodeSnippetFormatKey} + nameFormatter={getCodeSnippetFormatName} /> -
- : - -

- 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. -

-
- } -
; + + + 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. +

+
+ } + + {/* + * 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; - return getCodeSnippetOptionFromKey(exportSnippetFormat); + 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 old mode 100644 new mode 100755 index e881742c6..fd083ea39 --- a/src/components/view/multi-selection-summary-pane.tsx +++ b/src/components/view/multi-selection-summary-pane.tsx @@ -14,26 +14,25 @@ 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; - + overflow-y: auto; + padding: 0 20px 20px; 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; + display: flex; + flex-direction: column; + align-items: center; + flex-shrink: 0; + padding: 20px 0 0; + width: 100%; `; const PreviewRow = styled.div<{ @@ -41,85 +40,55 @@ const PreviewRow = styled.div<{ 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}; - + background-color: ${p => p.theme.mainBackground}; border-left: 5px solid ${p => p.markerColor}; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); + border-radius: 0 4px 4px 0; - 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}; + width: calc(100% - ${p => p.index * 20}px); + margin-top: ${p => p.index === 0 ? '0' : '-4px'}; + padding: 8px 12px; - overflow: hidden; white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + font-size: ${p => p.theme.textSize}; - transition: top 0.15s ease-out, - transform 0.15s ease-out, - opacity 0.15s ease-out; + ${p => p.dimRow && css` + opacity: 0.6; + `} `; 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}; + margin-top: 20px; + font-size: ${p => p.theme.headingSize}; 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` + width: 100%; + margin-top: 20px; display: flex; flex-direction: column; - align-items: stretch; gap: 10px; - margin-top: 30px; - width: 60%; - max-width: 360px; `; -const ActionButton = styled(Button)` +const ActionButton = styled(Button)<{ + disabled?: boolean +}>` + font-size: ${p => p.theme.textSize}; + padding: 8px 16px; + width: 100%; + font-weight: bold; + display: flex; align-items: center; - justify-content: flex-start; - gap: 12px; - padding: 10px 16px; - font-size: ${p => p.theme.textSize}; + gap: 10px; + + ${p => p.disabled && css` + opacity: 0.5; + `} > .fa-fw { width: 1.25em; @@ -141,19 +110,17 @@ const ProDivider = styled.hr` width: 100%; margin: 36px 0; border: none; - border: solid 1px ${p => p.theme.mainColor}; + border-top: 1px solid ${p => p.theme.containerBorder}; +`; + +const ProActionsOverlay = styled(GetProOverlay)` + width: 100%; `; 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%; @@ -169,13 +136,16 @@ export const MultiSelectionSummaryPane = inject('accountStore')(observer((props: onDelete: () => void, onBuildRule: () => void }) => { + const [zipDialogEvents, setZipDialogEvents] = React.useState | null>(null); + const { selectedEvents } = props; const count = selectedEvents.length; const isPaidUser = props.accountStore!.user.isPaidUser(); - const httpCount = selectedEvents.filter(e => + const exportableEvents = selectedEvents.filter(e => e.isHttp() && !e.isWebSocket() - ).length; + ); + const httpCount = exportableEvents.length; const allHttp = count > 0 && selectedEvents.every(e => e.isHttp()); const allPinned = selectedEvents.every(e => e.pinned); @@ -213,6 +183,17 @@ export const MultiSelectionSummaryPane = inject('accountStore')(observer((props: Export as HAR + setZipDialogEvents(exportableEvents)} + > + + Export as ZIP + ; return @@ -263,5 +244,11 @@ export const MultiSelectionSummaryPane = inject('accountStore')(observer((props: } + + {zipDialogEvents && setZipDialogEvents(null)} + titleSuffix={`${zipDialogEvents.length} request${zipDialogEvents.length !== 1 ? 's' : ''}`} + />} ; })); diff --git a/src/components/view/zip-download-panel.tsx b/src/components/view/zip-download-panel.tsx new file mode 100644 index 000000000..20c6794d7 --- /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/components/view/zip-export-dialog.tsx b/src/components/view/zip-export-dialog.tsx new file mode 100755 index 000000000..cfb001810 --- /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 69907e95b..ea72b2135 100644 Binary files a/src/model/ui/export.ts and b/src/model/ui/export.ts differ diff --git a/src/model/ui/snippet-export-sanitization.ts b/src/model/ui/snippet-export-sanitization.ts new file mode 100755 index 000000000..ebd62462b --- /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/snippet-formats.ts b/src/model/ui/snippet-formats.ts new file mode 100644 index 000000000..67179fd18 --- /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 e87af1a57..3429cc403 100644 --- a/src/model/ui/ui-store.ts +++ b/src/model/ui/ui-store.ts @@ -464,6 +464,37 @@ export class UiStore { @persist @observable exportSnippetFormat: string | undefined; + /** + * 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 @observable + private _zipExportSelection: string | undefined; + + @computed + 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 + setZipExportSelectedFormatIds(ids: string[]) { + this._zipExportSelection = JSON.stringify({ + version: 1, + ids: ids.filter(x => typeof x === 'string') + }); + } + // Actions for persisting view state when switching tabs @action.bound setViewScrollPosition(position: number | 'end') { @@ -567,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 000000000..281d3e893 --- /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 000000000..6a083ebe1 --- /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 000000000..fa4196e9b --- /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/model/ui/zip-metadata.ts b/src/model/ui/zip-metadata.ts new file mode 100644 index 000000000..2f73c3477 --- /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 7185032bd..09a4b4a20 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 { @@ -18,7 +19,13 @@ import type { FormatRequest, FormatResponse, ParseCertRequest, - ParseCertResponse + ParseCertResponse, + ZipExportRequest, + ZipExportResponse, + ZipExportProgressMessage, + ZipExportFormatTriple, + ZipExportPrewarmRequest, + ZipExportPrewarmResponse } from './ui-worker'; import { Headers, Omit } from '../types'; @@ -34,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 {} + }; + + // 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); + } - emitter.once(id.toString(), (data: R) => { + // 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 }; } @@ -86,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); } @@ -100,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; } @@ -153,4 +251,39 @@ export async function formatBufferAsync(buffer: Buffer, format: WorkerFormatterK format, headers, })).formatted; -} \ No newline at end of file +} + +/** + * 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; + +/** + * 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 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. + } +} + +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 c25f86b72..8924aece8 100644 --- a/src/services/ui-worker.ts +++ b/src/services/ui-worker.ts @@ -18,6 +18,22 @@ 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 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; @@ -100,6 +116,61 @@ export interface FormatResponse extends Message { formatted: string; } + +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 ZipExportResponse extends Message { + error?: Error; + 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 = | DecodeRequest | EncodeRequest @@ -107,7 +178,9 @@ export type BackgroundRequest = | BuildApiRequest | ValidatePKCSRequest | ParseCertRequest - | FormatRequest; + | FormatRequest + | ZipExportRequest + | ZipExportPrewarmRequest; export type BackgroundResponse = | DecodeResponse @@ -116,7 +189,9 @@ export type BackgroundResponse = | BuildApiResponse | ValidatePKCSResponse | ParseCertResponse - | FormatResponse; + | FormatResponse + | ZipExportResponse + | ZipExportPrewarmResponse; const bufferToArrayBuffer = (buffer: Buffer): ArrayBuffer => // Have to remember to slice: this can be a view into part of a much larger buffer! @@ -173,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) { @@ -228,13 +837,9 @@ ctx.addEventListener('message', async (event: { data: BackgroundRequest }) => { ctx.postMessage({ id: event.data.id, formatted }); 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': + await handleZipExport(event.data); + break; + + case 'zip-export-prewarm': + prewarmZipExportPath(); diff --git a/src/util/download.ts b/src/util/download.ts new file mode 100644 index 000000000..8a5bcd60b --- /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 000000000..26f6e9d97 Binary files /dev/null and b/src/util/export-filenames.ts differ 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 000000000..31c0996e9 --- /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/snippet-formats.spec.ts b/test/unit/model/ui/snippet-formats.spec.ts new file mode 100644 index 000000000..1639df43b --- /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/model/ui/zip-export-service.spec.ts b/test/unit/model/ui/zip-export-service.spec.ts new file mode 100755 index 000000000..a112adc3a --- /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/util/export-filenames.spec.ts b/test/unit/util/export-filenames.spec.ts new file mode 100644 index 000000000..2c403fd7d --- /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'); + }); + }); + +}); diff --git a/test/unit/workers/zip-export.spec.ts b/test/unit/workers/zip-export.spec.ts new file mode 100755 index 000000000..985f662e5 --- /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