tempest-react-sdk 0.38.2 → 0.38.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/lib/design/collect.mjs +19 -0
- package/bin/lib/design/functions.mjs +5 -0
- package/bin/lib/design/index.mjs +8 -3
- package/bin/lib/design/index.test.mjs +31 -0
- package/bin/lib/design/scan.mjs +34 -2
- package/bin/lib/design/scan.test.mjs +78 -0
- package/bin/lib/doctor/doctor.e2e.test.mjs +85 -0
- package/bin/tempest.mjs +90 -6
- package/dist/audio/level-meter.cjs.map +1 -1
- package/dist/audio/level-meter.js.map +1 -1
- package/dist/audio/use-audio-recorder.cjs.map +1 -1
- package/dist/audio/use-audio-recorder.js.map +1 -1
- package/dist/auth/passkey.cjs.map +1 -1
- package/dist/auth/passkey.js.map +1 -1
- package/dist/auth/use-passkey.cjs.map +1 -1
- package/dist/auth/use-passkey.js.map +1 -1
- package/dist/br/BrazilMap.cjs.map +1 -1
- package/dist/br/BrazilMap.js.map +1 -1
- package/dist/br/BrazilStateCitySelect.cjs.map +1 -1
- package/dist/br/BrazilStateCitySelect.js.map +1 -1
- package/dist/br/BrazilStateMap.cjs.map +1 -1
- package/dist/br/BrazilStateMap.js.map +1 -1
- package/dist/br/PixQRCode.cjs.map +1 -1
- package/dist/br/PixQRCode.js.map +1 -1
- package/dist/br/boleto.cjs.map +1 -1
- package/dist/br/boleto.js.map +1 -1
- package/dist/br/pix.cjs.map +1 -1
- package/dist/br/pix.js.map +1 -1
- package/dist/br.d.ts +8 -0
- package/dist/capture/media-recorder.cjs.map +1 -1
- package/dist/capture/media-recorder.js.map +1 -1
- package/dist/capture/use-barcode-scanner.cjs.map +1 -1
- package/dist/capture/use-barcode-scanner.js.map +1 -1
- package/dist/capture/use-screen-capture.cjs.map +1 -1
- package/dist/capture/use-screen-capture.js.map +1 -1
- package/dist/capture/use-speech-recognition.cjs.map +1 -1
- package/dist/capture/use-speech-recognition.js.map +1 -1
- package/dist/capture/use-video-recorder.cjs.map +1 -1
- package/dist/capture/use-video-recorder.js.map +1 -1
- package/dist/charts/types.cjs.map +1 -1
- package/dist/charts/types.js.map +1 -1
- package/dist/charts.d.ts +8 -0
- package/dist/components/AIChat/AIChat.cjs.map +1 -1
- package/dist/components/AIChat/AIChat.js.map +1 -1
- package/dist/components/AIChat/AIChatComposer.cjs.map +1 -1
- package/dist/components/AIChat/AIChatComposer.js.map +1 -1
- package/dist/components/AIChat/AIChatTurn.cjs.map +1 -1
- package/dist/components/AIChat/AIChatTurn.js.map +1 -1
- package/dist/components/AppBar/AppBar.cjs.map +1 -1
- package/dist/components/AppBar/AppBar.js.map +1 -1
- package/dist/components/AudioPlayer/AudioPlayer.cjs.map +1 -1
- package/dist/components/AudioPlayer/AudioPlayer.js.map +1 -1
- package/dist/components/AudioRecorder/AudioRecorder.cjs.map +1 -1
- package/dist/components/AudioRecorder/AudioRecorder.js.map +1 -1
- package/dist/components/BarcodeScanner/BarcodeScanner.cjs.map +1 -1
- package/dist/components/BarcodeScanner/BarcodeScanner.js.map +1 -1
- package/dist/components/Button/Button.cjs.map +1 -1
- package/dist/components/Button/Button.js.map +1 -1
- package/dist/components/Calendar/Calendar.cjs.map +1 -1
- package/dist/components/Calendar/Calendar.js.map +1 -1
- package/dist/components/Carousel/Carousel.cjs.map +1 -1
- package/dist/components/Carousel/Carousel.js.map +1 -1
- package/dist/components/Chat/Chat.cjs.map +1 -1
- package/dist/components/Chat/Chat.js.map +1 -1
- package/dist/components/ChipInput/ChipInput.cjs.map +1 -1
- package/dist/components/ChipInput/ChipInput.js.map +1 -1
- package/dist/components/CodeBlock/CodeBlock.cjs.map +1 -1
- package/dist/components/CodeBlock/CodeBlock.js.map +1 -1
- package/dist/components/Combobox/Combobox.cjs.map +1 -1
- package/dist/components/Combobox/Combobox.js.map +1 -1
- package/dist/components/Command/Command.cjs.map +1 -1
- package/dist/components/Command/Command.js.map +1 -1
- package/dist/components/ConfirmDialog/ConfirmDialog.cjs.map +1 -1
- package/dist/components/ConfirmDialog/ConfirmDialog.js.map +1 -1
- package/dist/components/ContextMenu/ContextMenu.cjs.map +1 -1
- package/dist/components/ContextMenu/ContextMenu.js.map +1 -1
- package/dist/components/CopyButton/CopyButton.cjs.map +1 -1
- package/dist/components/CopyButton/CopyButton.js.map +1 -1
- package/dist/components/DataTable/DataTable.cjs.map +1 -1
- package/dist/components/DataTable/DataTable.js.map +1 -1
- package/dist/components/DataTable/DataTable.module.cjs.map +1 -1
- package/dist/components/DataTable/DataTable.module.js.map +1 -1
- package/dist/components/DataTable/EditableCell.cjs.map +1 -1
- package/dist/components/DataTable/EditableCell.js.map +1 -1
- package/dist/components/DatePicker/DatePicker.cjs.map +1 -1
- package/dist/components/DatePicker/DatePicker.js.map +1 -1
- package/dist/components/DateRangePicker/DateRangePicker.cjs.map +1 -1
- package/dist/components/DateRangePicker/DateRangePicker.js.map +1 -1
- package/dist/components/Drawer/Drawer.cjs.map +1 -1
- package/dist/components/Drawer/Drawer.js.map +1 -1
- package/dist/components/DropdownMenu/DropdownMenu.cjs.map +1 -1
- package/dist/components/DropdownMenu/DropdownMenu.js.map +1 -1
- package/dist/components/Dropzone/Dropzone.cjs.map +1 -1
- package/dist/components/Dropzone/Dropzone.js.map +1 -1
- package/dist/components/FileUpload/FileUpload.cjs.map +1 -1
- package/dist/components/FileUpload/FileUpload.js.map +1 -1
- package/dist/components/FilterBar/FilterBar.cjs.map +1 -1
- package/dist/components/FilterBar/FilterBar.js.map +1 -1
- package/dist/components/ImageCropper/ImageCropper.cjs.map +1 -1
- package/dist/components/ImageCropper/ImageCropper.js.map +1 -1
- package/dist/components/InstallBanner/InstallBanner.cjs.map +1 -1
- package/dist/components/InstallBanner/InstallBanner.js.map +1 -1
- package/dist/components/Kanban/Kanban.cjs.map +1 -1
- package/dist/components/Kanban/Kanban.js.map +1 -1
- package/dist/components/Lightbox/Lightbox.cjs.map +1 -1
- package/dist/components/Lightbox/Lightbox.js.map +1 -1
- package/dist/components/ListTile/ListTile.cjs.map +1 -1
- package/dist/components/ListTile/ListTile.js.map +1 -1
- package/dist/components/Markdown/Markdown.cjs.map +1 -1
- package/dist/components/Markdown/Markdown.js.map +1 -1
- package/dist/components/Markdown/markdown-parse.cjs.map +1 -1
- package/dist/components/Markdown/markdown-parse.js.map +1 -1
- package/dist/components/Menubar/Menubar.cjs.map +1 -1
- package/dist/components/Menubar/Menubar.js.map +1 -1
- package/dist/components/Modal/Modal.cjs.map +1 -1
- package/dist/components/Modal/Modal.js.map +1 -1
- package/dist/components/ModalsManager/ModalsManager.cjs.map +1 -1
- package/dist/components/ModalsManager/ModalsManager.js.map +1 -1
- package/dist/components/MultiSelect/MultiSelect.cjs.map +1 -1
- package/dist/components/MultiSelect/MultiSelect.js.map +1 -1
- package/dist/components/NavigationMenu/NavigationMenu.cjs.map +1 -1
- package/dist/components/NavigationMenu/NavigationMenu.js.map +1 -1
- package/dist/components/NotificationCenter/NotificationCenter.cjs.map +1 -1
- package/dist/components/NotificationCenter/NotificationCenter.js.map +1 -1
- package/dist/components/Page/Page.cjs.map +1 -1
- package/dist/components/Page/Page.js.map +1 -1
- package/dist/components/Pagination/Pagination.cjs.map +1 -1
- package/dist/components/Pagination/Pagination.js.map +1 -1
- package/dist/components/PasswordInput/PasswordInput.cjs.map +1 -1
- package/dist/components/PasswordInput/PasswordInput.js.map +1 -1
- package/dist/components/PinInput/PinInput.cjs.map +1 -1
- package/dist/components/PinInput/PinInput.js.map +1 -1
- package/dist/components/Popover/Popover.cjs.map +1 -1
- package/dist/components/Popover/Popover.js.map +1 -1
- package/dist/components/Progress/Progress.cjs.map +1 -1
- package/dist/components/Progress/Progress.js.map +1 -1
- package/dist/components/QRCode/qr-encode.cjs.map +1 -1
- package/dist/components/QRCode/qr-encode.js.map +1 -1
- package/dist/components/Radio/Radio.cjs.map +1 -1
- package/dist/components/Radio/Radio.js.map +1 -1
- package/dist/components/RangeSlider/RangeSlider.cjs.map +1 -1
- package/dist/components/RangeSlider/RangeSlider.js.map +1 -1
- package/dist/components/RatingStars/RatingStars.cjs.map +1 -1
- package/dist/components/RatingStars/RatingStars.js.map +1 -1
- package/dist/components/RefreshIndicator/RefreshIndicator.cjs.map +1 -1
- package/dist/components/RefreshIndicator/RefreshIndicator.js.map +1 -1
- package/dist/components/Resizable/Resizable.cjs.map +1 -1
- package/dist/components/Resizable/Resizable.js.map +1 -1
- package/dist/components/Scheduler/Scheduler.cjs.map +1 -1
- package/dist/components/Scheduler/Scheduler.js.map +1 -1
- package/dist/components/Sidebar/Sidebar.cjs.map +1 -1
- package/dist/components/Sidebar/Sidebar.js.map +1 -1
- package/dist/components/SignaturePad/SignaturePad.cjs.map +1 -1
- package/dist/components/SignaturePad/SignaturePad.js.map +1 -1
- package/dist/components/Slider/Slider.cjs.map +1 -1
- package/dist/components/Slider/Slider.js.map +1 -1
- package/dist/components/Sparkline/Sparkline.cjs.map +1 -1
- package/dist/components/Sparkline/Sparkline.js.map +1 -1
- package/dist/components/StepperInput/StepperInput.cjs.map +1 -1
- package/dist/components/StepperInput/StepperInput.js.map +1 -1
- package/dist/components/Table/Table.cjs.map +1 -1
- package/dist/components/Table/Table.js.map +1 -1
- package/dist/components/TimePicker/TimePicker.cjs.map +1 -1
- package/dist/components/TimePicker/TimePicker.js.map +1 -1
- package/dist/components/Toast/ToastProvider.cjs.map +1 -1
- package/dist/components/Toast/ToastProvider.js.map +1 -1
- package/dist/components/Tour/Tour.cjs.map +1 -1
- package/dist/components/Tour/Tour.js.map +1 -1
- package/dist/components/Transfer/Transfer.cjs.map +1 -1
- package/dist/components/Transfer/Transfer.js.map +1 -1
- package/dist/components/TreeView/TreeView.cjs.map +1 -1
- package/dist/components/TreeView/TreeView.js.map +1 -1
- package/dist/components/VirtualList/VirtualList.cjs.map +1 -1
- package/dist/components/VirtualList/VirtualList.js.map +1 -1
- package/dist/components/VirtualTable/VirtualTable.cjs.map +1 -1
- package/dist/components/VirtualTable/VirtualTable.js.map +1 -1
- package/dist/components/Wizard/Wizard.cjs.map +1 -1
- package/dist/components/Wizard/Wizard.js.map +1 -1
- package/dist/editor/RichTextEditor.cjs.map +1 -1
- package/dist/editor/RichTextEditor.js.map +1 -1
- package/dist/forms/FormField.cjs.map +1 -1
- package/dist/forms/FormField.js.map +1 -1
- package/dist/geo/TrajectoryMap.cjs.map +1 -1
- package/dist/geo/TrajectoryMap.js.map +1 -1
- package/dist/geo/estimate.cjs.map +1 -1
- package/dist/geo/estimate.js.map +1 -1
- package/dist/geo/projection.cjs.map +1 -1
- package/dist/geo/projection.js.map +1 -1
- package/dist/hooks/use-event-listener.cjs.map +1 -1
- package/dist/hooks/use-event-listener.js.map +1 -1
- package/dist/hooks/use-local-storage.cjs.map +1 -1
- package/dist/hooks/use-local-storage.js.map +1 -1
- package/dist/hooks/use-sortable.cjs.map +1 -1
- package/dist/hooks/use-sortable.js.map +1 -1
- package/dist/http/api-client.cjs.map +1 -1
- package/dist/http/api-client.js.map +1 -1
- package/dist/http/errors.cjs.map +1 -1
- package/dist/http/errors.js.map +1 -1
- package/dist/http/resumable-upload.cjs.map +1 -1
- package/dist/http/resumable-upload.js.map +1 -1
- package/dist/http/upload-with-progress.cjs.map +1 -1
- package/dist/http/upload-with-progress.js.map +1 -1
- package/dist/i18n/I18nProvider.cjs.map +1 -1
- package/dist/i18n/I18nProvider.js.map +1 -1
- package/dist/imaging/canvas.cjs.map +1 -1
- package/dist/imaging/canvas.js.map +1 -1
- package/dist/imaging.d.ts +5 -0
- package/dist/logger/logger.cjs.map +1 -1
- package/dist/logger/logger.js.map +1 -1
- package/dist/oauth/GoogleSignIn.cjs.map +1 -1
- package/dist/oauth/GoogleSignIn.js.map +1 -1
- package/dist/offline/create-offline-sync.cjs.map +1 -1
- package/dist/offline/create-offline-sync.js.map +1 -1
- package/dist/perf/cache-size.cjs +1 -1
- package/dist/perf/cache-size.cjs.map +1 -1
- package/dist/perf/cache-size.js +27 -6
- package/dist/perf/cache-size.js.map +1 -1
- package/dist/sse/create-event-stream.cjs.map +1 -1
- package/dist/sse/create-event-stream.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/sw/background-sync.cjs.map +1 -1
- package/dist/sw/background-sync.js.map +1 -1
- package/dist/sw/cache.cjs.map +1 -1
- package/dist/sw/cache.js.map +1 -1
- package/dist/sw.d.ts +12 -0
- package/dist/tabular/compact.cjs.map +1 -1
- package/dist/tabular/compact.js.map +1 -1
- package/dist/tabular/predictor.cjs.map +1 -1
- package/dist/tabular/predictor.js.map +1 -1
- package/dist/tabular.d.ts +7 -2
- package/dist/tempest-react-sdk.d.ts +133 -6
- package/dist/theme/ThemeProvider.cjs.map +1 -1
- package/dist/theme/ThemeProvider.js.map +1 -1
- package/dist/theme/color.cjs.map +1 -1
- package/dist/theme/color.js.map +1 -1
- package/dist/theme/create-theme.cjs.map +1 -1
- package/dist/theme/create-theme.js.map +1 -1
- package/dist/theme/data-viz-ramps.cjs +1 -1
- package/dist/theme/data-viz-ramps.cjs.map +1 -1
- package/dist/theme/data-viz-ramps.js +1 -1
- package/dist/theme/data-viz-ramps.js.map +1 -1
- package/dist/utils/storage.cjs.map +1 -1
- package/dist/utils/storage.js.map +1 -1
- package/dist/vision/core/canvas.cjs.map +1 -1
- package/dist/vision/core/canvas.js.map +1 -1
- package/dist/vision/core/exceptions.cjs.map +1 -1
- package/dist/vision/core/exceptions.js.map +1 -1
- package/dist/vision/core/graph.cjs.map +1 -1
- package/dist/vision/core/graph.js.map +1 -1
- package/dist/vision/core/metadata.cjs.map +1 -1
- package/dist/vision/core/metadata.js.map +1 -1
- package/dist/vision/core/providers.cjs.map +1 -1
- package/dist/vision/core/providers.js.map +1 -1
- package/dist/vision/core/session.cjs.map +1 -1
- package/dist/vision/core/session.js.map +1 -1
- package/dist/vision/core/timing.cjs.map +1 -1
- package/dist/vision/core/timing.js.map +1 -1
- package/dist/vision/io/image.cjs.map +1 -1
- package/dist/vision/io/image.js.map +1 -1
- package/dist/vision/labels.cjs.map +1 -1
- package/dist/vision/labels.js.map +1 -1
- package/dist/vision/luminance.cjs.map +1 -1
- package/dist/vision/luminance.js.map +1 -1
- package/dist/vision/postprocess/classification.cjs.map +1 -1
- package/dist/vision/postprocess/classification.js.map +1 -1
- package/dist/vision/postprocess/detection.cjs.map +1 -1
- package/dist/vision/postprocess/detection.js.map +1 -1
- package/dist/vision/postprocess/segmentation.cjs.map +1 -1
- package/dist/vision/postprocess/segmentation.js.map +1 -1
- package/dist/vision/preprocess/image.cjs.map +1 -1
- package/dist/vision/preprocess/image.js.map +1 -1
- package/dist/vision/results.cjs.map +1 -1
- package/dist/vision/results.js.map +1 -1
- package/dist/vision/tasks/base.cjs.map +1 -1
- package/dist/vision/tasks/base.js.map +1 -1
- package/dist/vision/tasks/classifier.cjs.map +1 -1
- package/dist/vision/tasks/classifier.js.map +1 -1
- package/dist/vision/tasks/detector.cjs.map +1 -1
- package/dist/vision/tasks/detector.js.map +1 -1
- package/dist/vision/tasks/segmenter.cjs.map +1 -1
- package/dist/vision/tasks/segmenter.js.map +1 -1
- package/dist/vision/types.cjs.map +1 -1
- package/dist/vision/types.js.map +1 -1
- package/dist/vision.d.ts +6 -0
- package/dist/vite/tempest-icons.cjs.map +1 -1
- package/dist/vite/tempest-icons.js.map +1 -1
- package/dist/vite/tempest-pwa-icons.cjs.map +1 -1
- package/dist/vite/tempest-pwa-icons.js.map +1 -1
- package/dist/ws/create-web-socket.cjs.map +1 -1
- package/dist/ws/create-web-socket.js.map +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-sortable.cjs","names":[],"sources":["../../src/hooks/use-sortable.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from \"react\";\nimport type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from \"react\";\n\n/** Where a dragged item would land if dropped now. */\nexport interface SortableDropTarget {\n /** Index the item started at, within its own group. */\n from: number;\n /** Index it would move to, within the destination group. */\n to: number;\n /**\n * Group the item started in — a Kanban column, for instance. `undefined` for a\n * plain single-list sortable.\n */\n fromGroup?: string;\n /** Destination group. Equal to `fromGroup` when the move stays in one group. */\n toGroup?: string;\n}\n\n/** Props to spread on each sortable item. */\nexport interface SortableItemProps {\n /** Marks the item for hit-testing. */\n \"data-sortable-index\": number;\n /** Group the item belongs to, when the sortable spans several lists. */\n \"data-sortable-group\"?: string;\n /** `true` while this item is the one being moved. */\n \"data-sortable-active\"?: boolean;\n /** Grab handle wiring: starts a pointer drag. */\n onPointerDown: (event: ReactPointerEvent<HTMLElement>) => void;\n /** Keyboard reordering — see {@link UseSortableResult}. */\n onKeyDown: (event: ReactKeyboardEvent<HTMLElement>) => void;\n /** Puts the item in the tab order so the keyboard path is reachable. */\n tabIndex: number;\n role: \"option\";\n \"aria-roledescription\": string;\n \"aria-selected\": boolean;\n}\n\nexport interface UseSortableOptions {\n /**\n * How many items are in the sortable. Changing it cancels an in-flight drag.\n *\n * With groups, pass the **total** across groups: the number only has to change\n * whenever the indices the drag was based on stop being valid.\n */\n itemCount: number;\n /**\n * Called once, when a move is committed — on pointer release or on a keyboard\n * move. Never called mid-drag, so a controlled list re-renders once instead of\n * on every pointer frame.\n */\n onReorder: (target: SortableDropTarget) => void;\n /** Blocks all interaction. */\n disabled?: boolean;\n /**\n * Description announced for each item, so a screen reader user learns the\n * keyboard contract. Default is in English — override it to localize.\n */\n roleDescription?: string;\n}\n\nexport interface UseSortableResult {\n /** Index being dragged, or `null` when idle. */\n activeIndex: number | null;\n /** Index the item would land on, or `null` when idle. */\n overIndex: number | null;\n /** Group the dragged item came from, when using groups. */\n activeGroup?: string;\n /** Group the item is currently over, when using groups. */\n overGroup?: string;\n /**\n * Spread on each item. Pass `group` when the sortable spans several lists (a\n * Kanban column id), so a drop can land in a different group.\n */\n getItemProps: (index: number, group?: string) => SortableItemProps;\n /**\n * Spread on an **empty** group's drop area, so an item can be moved into a\n * column that has no cards yet — hit-testing works off item rects, and a group\n * with no items has none.\n */\n getEmptyGroupProps: (group: string) => {\n \"data-sortable-group\": string;\n \"data-sortable-empty\": true;\n };\n /** Spread on the list container (`role=\"listbox\"` + a label of your own). */\n getListProps: () => { role: \"listbox\"; \"aria-orientation\": \"vertical\" };\n /** Abort the current drag without reordering. */\n cancel: () => void;\n /**\n * Callback ref for the element that contains the sortable items — hit-testing\n * searches inside it.\n *\n * Named `setContainer` rather than `ref` because it is a **callback**, not a ref\n * object: `ref={sortable.setContainer}`. The old name also read as a readable\n * ref to the React Compiler rules, which flagged every consumer.\n */\n setContainer: (node: HTMLElement | null) => void;\n}\n\n/** Reorder helper: move `from` to `to`, returning a new array. */\nexport function moveItem<T>(items: T[], from: number, to: number): T[] {\n if (from === to || from < 0 || to < 0 || from >= items.length || to >= items.length) {\n return items;\n }\n const next = items.slice();\n const [moved] = next.splice(from, 1);\n next.splice(to, 0, moved);\n return next;\n}\n\n/** A hit-test result: the item (or empty group) under the pointer. */\ninterface HitTarget {\n index: number;\n group?: string;\n}\n\n/**\n * Find the sortable slot under a viewport point.\n *\n * Items are tried first; an empty group's drop area is only considered when no item\n * matched, since an empty column is a fallback target rather than a competing one.\n */\nfunction targetAtPoint(container: HTMLElement | null, x: number, y: number): HitTarget | null {\n if (!container) return null;\n const inside = (element: HTMLElement): boolean => {\n const rect = element.getBoundingClientRect();\n return y >= rect.top && y <= rect.bottom && x >= rect.left && x <= rect.right;\n };\n\n for (const item of Array.from(\n container.querySelectorAll<HTMLElement>(\"[data-sortable-index]\"),\n )) {\n if (!inside(item)) continue;\n const index = Number(item.dataset.sortableIndex);\n if (Number.isNaN(index)) return null;\n return { index, group: item.dataset.sortableGroup };\n }\n\n for (const empty of Array.from(\n container.querySelectorAll<HTMLElement>(\"[data-sortable-empty]\"),\n )) {\n if (inside(empty)) return { index: 0, group: empty.dataset.sortableGroup };\n }\n\n return null;\n}\n\n/**\n * Drag-to-reorder for a list, with a **keyboard path of equal standing**.\n *\n * Pointer events cover mouse, touch and stylus through one code path, and pointer\n * capture keeps the drag alive when the pointer leaves the item. `Space` picks an\n * item up, the arrows move it, `Space`/`Enter` drops it and `Escape` cancels —\n * because a reorder that only works by dragging excludes keyboard users\n * completely, which is where most drag-and-drop implementations fail.\n *\n * The hook owns interaction only: it never mutates your data. `onReorder` fires\n * once per committed move and you apply it, typically with {@link moveItem}. That\n * keeps the list a controlled value and avoids a re-render per pointer frame.\n *\n * Hit-testing reads the live DOM rects of `[data-sortable-index]` children rather\n * than assuming a fixed row height, so it works with rows of different sizes.\n *\n * A change in `itemCount` cancels an in-flight drag: the list no longer has the\n * indices the drag was based on, and committing anyway would move the wrong row.\n *\n * Note for consumers: the indices and groups are exposed as **state** (so rendering\n * from them is reactive) and mirrored into refs only so the window-level `pointerup`\n * listener can read both synchronously. Because the handlers returned by\n * `getItemProps` close over those mirrors, the React Compiler `refs` rule reports\n * the *call site* — a component spreading these props may need a file-level\n * exemption, as `Kanban` documents.\n *\n * @example\n * ```tsx\n * const [items, setItems] = useState([\"Alfa\", \"Bravo\", \"Charlie\"]);\n * const sortable = useSortable({\n * itemCount: items.length,\n * onReorder: ({ from, to }) => setItems((current) => moveItem(current, from, to)),\n * });\n *\n * <ul {...sortable.getListProps()} aria-label=\"Prioridade\" ref={sortable.setContainer}>\n * {items.map((item, index) => (\n * <li key={item} {...sortable.getItemProps(index)}>\n * {item}\n * </li>\n * ))}\n * </ul>\n * ```\n */\nexport function useSortable(options: UseSortableOptions): UseSortableResult {\n const { itemCount, onReorder, disabled = false, roleDescription = \"Sortable item\" } = options;\n\n const [activeIndex, setActiveIndexState] = useState<number | null>(null);\n const [overIndex, setOverIndexState] = useState<number | null>(null);\n const containerRef = useRef<HTMLElement | null>(null);\n const onReorderRef = useRef(onReorder);\n\n /**\n * Mirrors of the two indices.\n *\n * The window-level `pointerup` listener has to read both values *synchronously*\n * to know what to commit, and a state setter only exposes one of them. Mirroring\n * into refs keeps the commit readable instead of nesting setters to smuggle the\n * other value out.\n */\n const activeRef = useRef<number | null>(null);\n const overRef = useRef<number | null>(null);\n const [activeGroup, setActiveGroupState] = useState<string | undefined>(undefined);\n const [overGroup, setOverGroupState] = useState<string | undefined>(undefined);\n const activeGroupRef = useRef<string | undefined>(undefined);\n const overGroupRef = useRef<string | undefined>(undefined);\n\n const setActiveGroup = useCallback((value: string | undefined): void => {\n activeGroupRef.current = value;\n setActiveGroupState(value);\n }, []);\n\n const setOverGroup = useCallback((value: string | undefined): void => {\n overGroupRef.current = value;\n setOverGroupState(value);\n }, []);\n\n const setActiveIndex = useCallback((value: number | null): void => {\n activeRef.current = value;\n setActiveIndexState(value);\n }, []);\n\n const setOverIndex = useCallback((value: number | null): void => {\n overRef.current = value;\n setOverIndexState(value);\n }, []);\n\n useEffect(() => {\n onReorderRef.current = onReorder;\n }, [onReorder]);\n\n const reset = useCallback((): void => {\n setActiveGroup(undefined);\n setOverGroup(undefined);\n setActiveIndex(null);\n setOverIndex(null);\n }, [setActiveGroup, setActiveIndex, setOverGroup, setOverIndex]);\n\n useEffect(() => {\n reset();\n }, [itemCount, reset]);\n\n /**\n * Report a committed move and go back to idle.\n *\n * The groups are **arguments**, not reads of the mirror refs: `commit` ends up\n * inside the props returned by `getItemProps`, which render calls, and a ref read\n * anywhere in that closure chain is a render-time ref access. The pointer\n * listener reads the mirrors itself — it is an event handler, where reading is\n * fine — and hands the values over.\n *\n * A move that changes group counts even at the same index (the card lands in\n * another column), so only a same-group, same-index drop is \"nothing happened\".\n */\n const commit = useCallback(\n (from: number, to: number, fromGroup?: string, toGroup?: string): void => {\n if (from !== to || fromGroup !== toGroup) {\n onReorderRef.current({ from, to, fromGroup, toGroup });\n }\n reset();\n },\n [reset],\n );\n\n const setContainer = useCallback((node: HTMLElement | null): void => {\n containerRef.current = node;\n }, []);\n\n const handlePointerDown = useCallback(\n (index: number, group: string | undefined, event: ReactPointerEvent<HTMLElement>): void => {\n if (disabled) return;\n event.currentTarget.setPointerCapture?.(event.pointerId);\n setActiveGroup(group);\n setOverGroup(group);\n setActiveIndex(index);\n setOverIndex(index);\n },\n [disabled, setActiveGroup, setActiveIndex, setOverGroup, setOverIndex],\n );\n\n useEffect(() => {\n if (activeIndex === null) return;\n\n function onPointerMove(event: PointerEvent): void {\n const target = targetAtPoint(containerRef.current, event.clientX, event.clientY);\n if (!target) return;\n setOverGroup(target.group);\n setOverIndex(target.index);\n }\n\n function onPointerUp(): void {\n const from = activeRef.current;\n const to = overRef.current;\n if (from !== null && to !== null) {\n commit(from, to, activeGroupRef.current, overGroupRef.current);\n } else {\n reset();\n }\n }\n\n function onKeyDown(event: KeyboardEvent): void {\n if (event.key === \"Escape\") reset();\n }\n\n window.addEventListener(\"pointermove\", onPointerMove);\n window.addEventListener(\"pointerup\", onPointerUp);\n window.addEventListener(\"pointercancel\", reset);\n window.addEventListener(\"keydown\", onKeyDown);\n return () => {\n window.removeEventListener(\"pointermove\", onPointerMove);\n window.removeEventListener(\"pointerup\", onPointerUp);\n window.removeEventListener(\"pointercancel\", reset);\n window.removeEventListener(\"keydown\", onKeyDown);\n };\n }, [activeIndex, commit, reset, setOverGroup, setOverIndex]);\n\n const handleKeyDown = useCallback(\n (\n index: number,\n group: string | undefined,\n event: ReactKeyboardEvent<HTMLElement>,\n ): void => {\n if (disabled) return;\n const picked = activeIndex !== null;\n\n switch (event.key) {\n case \" \":\n case \"Enter\":\n event.preventDefault();\n if (!picked) {\n setActiveGroup(group);\n setOverGroup(group);\n setActiveIndex(index);\n setOverIndex(index);\n } else if (overIndex !== null) {\n commit(activeIndex, overIndex, activeGroup, overGroup);\n }\n break;\n case \"ArrowDown\":\n case \"ArrowRight\": {\n if (!picked) return;\n event.preventDefault();\n if (overRef.current !== null) {\n setOverIndex(Math.min(overRef.current + 1, itemCount - 1));\n }\n break;\n }\n case \"ArrowUp\":\n case \"ArrowLeft\": {\n if (!picked) return;\n event.preventDefault();\n if (overRef.current !== null) {\n setOverIndex(Math.max(overRef.current - 1, 0));\n }\n break;\n }\n case \"Escape\":\n if (!picked) return;\n event.preventDefault();\n reset();\n break;\n default:\n break;\n }\n },\n [\n activeGroup,\n activeIndex,\n commit,\n overGroup,\n disabled,\n itemCount,\n overIndex,\n reset,\n setActiveGroup,\n setActiveIndex,\n setOverGroup,\n setOverIndex,\n ],\n );\n\n const getItemProps = useCallback(\n (index: number, group?: string): SortableItemProps => ({\n \"data-sortable-index\": index,\n \"data-sortable-group\": group,\n \"data-sortable-active\": (activeIndex === index && activeGroup === group) || undefined,\n onPointerDown: (event) => handlePointerDown(index, group, event),\n onKeyDown: (event) => handleKeyDown(index, group, event),\n tabIndex: disabled ? -1 : 0,\n role: \"option\",\n \"aria-roledescription\": roleDescription,\n \"aria-selected\": activeIndex === index && activeGroup === group,\n }),\n [activeGroup, activeIndex, disabled, handleKeyDown, handlePointerDown, roleDescription],\n );\n\n const getEmptyGroupProps = useCallback(\n (group: string) => ({ \"data-sortable-group\": group, \"data-sortable-empty\": true as const }),\n [],\n );\n\n const getListProps = useCallback(\n () => ({ role: \"listbox\" as const, \"aria-orientation\": \"vertical\" as const }),\n [],\n );\n\n return {\n activeIndex,\n overIndex,\n activeGroup,\n overGroup,\n getItemProps,\n getEmptyGroupProps,\n getListProps,\n cancel: reset,\n setContainer,\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"use-sortable.cjs","names":[],"sources":["../../src/hooks/use-sortable.ts"],"sourcesContent":["/**\n * @tempest-limits file-lines, hook-lines — drag-and-drop with a keyboard path that\n * has to produce the same moves as the pointer path: pointer capture, auto-scroll\n * near the edges, the drop-index maths and the live-region announcements all read\n * one drag state. Two hooks would mean two copies of the index arithmetic, and the\n * two paths silently disagreeing is the bug this exists to prevent.\n */\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from \"react\";\n\n/** Where a dragged item would land if dropped now. */\nexport interface SortableDropTarget {\n /** Index the item started at, within its own group. */\n from: number;\n /** Index it would move to, within the destination group. */\n to: number;\n /**\n * Group the item started in — a Kanban column, for instance. `undefined` for a\n * plain single-list sortable.\n */\n fromGroup?: string;\n /** Destination group. Equal to `fromGroup` when the move stays in one group. */\n toGroup?: string;\n}\n\n/** Props to spread on each sortable item. */\nexport interface SortableItemProps {\n /** Marks the item for hit-testing. */\n \"data-sortable-index\": number;\n /** Group the item belongs to, when the sortable spans several lists. */\n \"data-sortable-group\"?: string;\n /** `true` while this item is the one being moved. */\n \"data-sortable-active\"?: boolean;\n /** Grab handle wiring: starts a pointer drag. */\n onPointerDown: (event: ReactPointerEvent<HTMLElement>) => void;\n /** Keyboard reordering — see {@link UseSortableResult}. */\n onKeyDown: (event: ReactKeyboardEvent<HTMLElement>) => void;\n /** Puts the item in the tab order so the keyboard path is reachable. */\n tabIndex: number;\n role: \"option\";\n \"aria-roledescription\": string;\n \"aria-selected\": boolean;\n}\n\nexport interface UseSortableOptions {\n /**\n * How many items are in the sortable. Changing it cancels an in-flight drag.\n *\n * With groups, pass the **total** across groups: the number only has to change\n * whenever the indices the drag was based on stop being valid.\n */\n itemCount: number;\n /**\n * Called once, when a move is committed — on pointer release or on a keyboard\n * move. Never called mid-drag, so a controlled list re-renders once instead of\n * on every pointer frame.\n */\n onReorder: (target: SortableDropTarget) => void;\n /** Blocks all interaction. */\n disabled?: boolean;\n /**\n * Description announced for each item, so a screen reader user learns the\n * keyboard contract. Default is in English — override it to localize.\n */\n roleDescription?: string;\n}\n\nexport interface UseSortableResult {\n /** Index being dragged, or `null` when idle. */\n activeIndex: number | null;\n /** Index the item would land on, or `null` when idle. */\n overIndex: number | null;\n /** Group the dragged item came from, when using groups. */\n activeGroup?: string;\n /** Group the item is currently over, when using groups. */\n overGroup?: string;\n /**\n * Spread on each item. Pass `group` when the sortable spans several lists (a\n * Kanban column id), so a drop can land in a different group.\n */\n getItemProps: (index: number, group?: string) => SortableItemProps;\n /**\n * Spread on an **empty** group's drop area, so an item can be moved into a\n * column that has no cards yet — hit-testing works off item rects, and a group\n * with no items has none.\n */\n getEmptyGroupProps: (group: string) => {\n \"data-sortable-group\": string;\n \"data-sortable-empty\": true;\n };\n /** Spread on the list container (`role=\"listbox\"` + a label of your own). */\n getListProps: () => { role: \"listbox\"; \"aria-orientation\": \"vertical\" };\n /** Abort the current drag without reordering. */\n cancel: () => void;\n /**\n * Callback ref for the element that contains the sortable items — hit-testing\n * searches inside it.\n *\n * Named `setContainer` rather than `ref` because it is a **callback**, not a ref\n * object: `ref={sortable.setContainer}`. The old name also read as a readable\n * ref to the React Compiler rules, which flagged every consumer.\n */\n setContainer: (node: HTMLElement | null) => void;\n}\n\n/** Reorder helper: move `from` to `to`, returning a new array. */\nexport function moveItem<T>(items: T[], from: number, to: number): T[] {\n if (from === to || from < 0 || to < 0 || from >= items.length || to >= items.length) {\n return items;\n }\n const next = items.slice();\n const [moved] = next.splice(from, 1);\n next.splice(to, 0, moved);\n return next;\n}\n\n/** A hit-test result: the item (or empty group) under the pointer. */\ninterface HitTarget {\n index: number;\n group?: string;\n}\n\n/**\n * Find the sortable slot under a viewport point.\n *\n * Items are tried first; an empty group's drop area is only considered when no item\n * matched, since an empty column is a fallback target rather than a competing one.\n */\nfunction targetAtPoint(container: HTMLElement | null, x: number, y: number): HitTarget | null {\n if (!container) return null;\n const inside = (element: HTMLElement): boolean => {\n const rect = element.getBoundingClientRect();\n return y >= rect.top && y <= rect.bottom && x >= rect.left && x <= rect.right;\n };\n\n for (const item of Array.from(\n container.querySelectorAll<HTMLElement>(\"[data-sortable-index]\"),\n )) {\n if (!inside(item)) continue;\n const index = Number(item.dataset.sortableIndex);\n if (Number.isNaN(index)) return null;\n return { index, group: item.dataset.sortableGroup };\n }\n\n for (const empty of Array.from(\n container.querySelectorAll<HTMLElement>(\"[data-sortable-empty]\"),\n )) {\n if (inside(empty)) return { index: 0, group: empty.dataset.sortableGroup };\n }\n\n return null;\n}\n\n/**\n * Drag-to-reorder for a list, with a **keyboard path of equal standing**.\n *\n * Pointer events cover mouse, touch and stylus through one code path, and pointer\n * capture keeps the drag alive when the pointer leaves the item. `Space` picks an\n * item up, the arrows move it, `Space`/`Enter` drops it and `Escape` cancels —\n * because a reorder that only works by dragging excludes keyboard users\n * completely, which is where most drag-and-drop implementations fail.\n *\n * The hook owns interaction only: it never mutates your data. `onReorder` fires\n * once per committed move and you apply it, typically with {@link moveItem}. That\n * keeps the list a controlled value and avoids a re-render per pointer frame.\n *\n * Hit-testing reads the live DOM rects of `[data-sortable-index]` children rather\n * than assuming a fixed row height, so it works with rows of different sizes.\n *\n * A change in `itemCount` cancels an in-flight drag: the list no longer has the\n * indices the drag was based on, and committing anyway would move the wrong row.\n *\n * Note for consumers: the indices and groups are exposed as **state** (so rendering\n * from them is reactive) and mirrored into refs only so the window-level `pointerup`\n * listener can read both synchronously. Because the handlers returned by\n * `getItemProps` close over those mirrors, the React Compiler `refs` rule reports\n * the *call site* — a component spreading these props may need a file-level\n * exemption, as `Kanban` documents.\n *\n * @example\n * ```tsx\n * const [items, setItems] = useState([\"Alfa\", \"Bravo\", \"Charlie\"]);\n * const sortable = useSortable({\n * itemCount: items.length,\n * onReorder: ({ from, to }) => setItems((current) => moveItem(current, from, to)),\n * });\n *\n * <ul {...sortable.getListProps()} aria-label=\"Prioridade\" ref={sortable.setContainer}>\n * {items.map((item, index) => (\n * <li key={item} {...sortable.getItemProps(index)}>\n * {item}\n * </li>\n * ))}\n * </ul>\n * ```\n */\nexport function useSortable(options: UseSortableOptions): UseSortableResult {\n const { itemCount, onReorder, disabled = false, roleDescription = \"Sortable item\" } = options;\n\n const [activeIndex, setActiveIndexState] = useState<number | null>(null);\n const [overIndex, setOverIndexState] = useState<number | null>(null);\n const containerRef = useRef<HTMLElement | null>(null);\n const onReorderRef = useRef(onReorder);\n\n /**\n * Mirrors of the two indices.\n *\n * The window-level `pointerup` listener has to read both values *synchronously*\n * to know what to commit, and a state setter only exposes one of them. Mirroring\n * into refs keeps the commit readable instead of nesting setters to smuggle the\n * other value out.\n */\n const activeRef = useRef<number | null>(null);\n const overRef = useRef<number | null>(null);\n const [activeGroup, setActiveGroupState] = useState<string | undefined>(undefined);\n const [overGroup, setOverGroupState] = useState<string | undefined>(undefined);\n const activeGroupRef = useRef<string | undefined>(undefined);\n const overGroupRef = useRef<string | undefined>(undefined);\n\n const setActiveGroup = useCallback((value: string | undefined): void => {\n activeGroupRef.current = value;\n setActiveGroupState(value);\n }, []);\n\n const setOverGroup = useCallback((value: string | undefined): void => {\n overGroupRef.current = value;\n setOverGroupState(value);\n }, []);\n\n const setActiveIndex = useCallback((value: number | null): void => {\n activeRef.current = value;\n setActiveIndexState(value);\n }, []);\n\n const setOverIndex = useCallback((value: number | null): void => {\n overRef.current = value;\n setOverIndexState(value);\n }, []);\n\n useEffect(() => {\n onReorderRef.current = onReorder;\n }, [onReorder]);\n\n const reset = useCallback((): void => {\n setActiveGroup(undefined);\n setOverGroup(undefined);\n setActiveIndex(null);\n setOverIndex(null);\n }, [setActiveGroup, setActiveIndex, setOverGroup, setOverIndex]);\n\n useEffect(() => {\n reset();\n }, [itemCount, reset]);\n\n /**\n * Report a committed move and go back to idle.\n *\n * The groups are **arguments**, not reads of the mirror refs: `commit` ends up\n * inside the props returned by `getItemProps`, which render calls, and a ref read\n * anywhere in that closure chain is a render-time ref access. The pointer\n * listener reads the mirrors itself — it is an event handler, where reading is\n * fine — and hands the values over.\n *\n * A move that changes group counts even at the same index (the card lands in\n * another column), so only a same-group, same-index drop is \"nothing happened\".\n */\n const commit = useCallback(\n (from: number, to: number, fromGroup?: string, toGroup?: string): void => {\n if (from !== to || fromGroup !== toGroup) {\n onReorderRef.current({ from, to, fromGroup, toGroup });\n }\n reset();\n },\n [reset],\n );\n\n const setContainer = useCallback((node: HTMLElement | null): void => {\n containerRef.current = node;\n }, []);\n\n const handlePointerDown = useCallback(\n (index: number, group: string | undefined, event: ReactPointerEvent<HTMLElement>): void => {\n if (disabled) return;\n event.currentTarget.setPointerCapture?.(event.pointerId);\n setActiveGroup(group);\n setOverGroup(group);\n setActiveIndex(index);\n setOverIndex(index);\n },\n [disabled, setActiveGroup, setActiveIndex, setOverGroup, setOverIndex],\n );\n\n useEffect(() => {\n if (activeIndex === null) return;\n\n function onPointerMove(event: PointerEvent): void {\n const target = targetAtPoint(containerRef.current, event.clientX, event.clientY);\n if (!target) return;\n setOverGroup(target.group);\n setOverIndex(target.index);\n }\n\n function onPointerUp(): void {\n const from = activeRef.current;\n const to = overRef.current;\n if (from !== null && to !== null) {\n commit(from, to, activeGroupRef.current, overGroupRef.current);\n } else {\n reset();\n }\n }\n\n function onKeyDown(event: KeyboardEvent): void {\n if (event.key === \"Escape\") reset();\n }\n\n window.addEventListener(\"pointermove\", onPointerMove);\n window.addEventListener(\"pointerup\", onPointerUp);\n window.addEventListener(\"pointercancel\", reset);\n window.addEventListener(\"keydown\", onKeyDown);\n return () => {\n window.removeEventListener(\"pointermove\", onPointerMove);\n window.removeEventListener(\"pointerup\", onPointerUp);\n window.removeEventListener(\"pointercancel\", reset);\n window.removeEventListener(\"keydown\", onKeyDown);\n };\n }, [activeIndex, commit, reset, setOverGroup, setOverIndex]);\n\n const handleKeyDown = useCallback(\n (\n index: number,\n group: string | undefined,\n event: ReactKeyboardEvent<HTMLElement>,\n ): void => {\n if (disabled) return;\n const picked = activeIndex !== null;\n\n switch (event.key) {\n case \" \":\n case \"Enter\":\n event.preventDefault();\n if (!picked) {\n setActiveGroup(group);\n setOverGroup(group);\n setActiveIndex(index);\n setOverIndex(index);\n } else if (overIndex !== null) {\n commit(activeIndex, overIndex, activeGroup, overGroup);\n }\n break;\n case \"ArrowDown\":\n case \"ArrowRight\": {\n if (!picked) return;\n event.preventDefault();\n if (overRef.current !== null) {\n setOverIndex(Math.min(overRef.current + 1, itemCount - 1));\n }\n break;\n }\n case \"ArrowUp\":\n case \"ArrowLeft\": {\n if (!picked) return;\n event.preventDefault();\n if (overRef.current !== null) {\n setOverIndex(Math.max(overRef.current - 1, 0));\n }\n break;\n }\n case \"Escape\":\n if (!picked) return;\n event.preventDefault();\n reset();\n break;\n default:\n break;\n }\n },\n [\n activeGroup,\n activeIndex,\n commit,\n overGroup,\n disabled,\n itemCount,\n overIndex,\n reset,\n setActiveGroup,\n setActiveIndex,\n setOverGroup,\n setOverIndex,\n ],\n );\n\n const getItemProps = useCallback(\n (index: number, group?: string): SortableItemProps => ({\n \"data-sortable-index\": index,\n \"data-sortable-group\": group,\n \"data-sortable-active\": (activeIndex === index && activeGroup === group) || undefined,\n onPointerDown: (event) => handlePointerDown(index, group, event),\n onKeyDown: (event) => handleKeyDown(index, group, event),\n tabIndex: disabled ? -1 : 0,\n role: \"option\",\n \"aria-roledescription\": roleDescription,\n \"aria-selected\": activeIndex === index && activeGroup === group,\n }),\n [activeGroup, activeIndex, disabled, handleKeyDown, handlePointerDown, roleDescription],\n );\n\n const getEmptyGroupProps = useCallback(\n (group: string) => ({ \"data-sortable-group\": group, \"data-sortable-empty\": true as const }),\n [],\n );\n\n const getListProps = useCallback(\n () => ({ role: \"listbox\" as const, \"aria-orientation\": \"vertical\" as const }),\n [],\n );\n\n return {\n activeIndex,\n overIndex,\n activeGroup,\n overGroup,\n getItemProps,\n getEmptyGroupProps,\n getListProps,\n cancel: reset,\n setContainer,\n };\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-sortable.js","names":[],"sources":["../../src/hooks/use-sortable.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from \"react\";\nimport type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from \"react\";\n\n/** Where a dragged item would land if dropped now. */\nexport interface SortableDropTarget {\n /** Index the item started at, within its own group. */\n from: number;\n /** Index it would move to, within the destination group. */\n to: number;\n /**\n * Group the item started in — a Kanban column, for instance. `undefined` for a\n * plain single-list sortable.\n */\n fromGroup?: string;\n /** Destination group. Equal to `fromGroup` when the move stays in one group. */\n toGroup?: string;\n}\n\n/** Props to spread on each sortable item. */\nexport interface SortableItemProps {\n /** Marks the item for hit-testing. */\n \"data-sortable-index\": number;\n /** Group the item belongs to, when the sortable spans several lists. */\n \"data-sortable-group\"?: string;\n /** `true` while this item is the one being moved. */\n \"data-sortable-active\"?: boolean;\n /** Grab handle wiring: starts a pointer drag. */\n onPointerDown: (event: ReactPointerEvent<HTMLElement>) => void;\n /** Keyboard reordering — see {@link UseSortableResult}. */\n onKeyDown: (event: ReactKeyboardEvent<HTMLElement>) => void;\n /** Puts the item in the tab order so the keyboard path is reachable. */\n tabIndex: number;\n role: \"option\";\n \"aria-roledescription\": string;\n \"aria-selected\": boolean;\n}\n\nexport interface UseSortableOptions {\n /**\n * How many items are in the sortable. Changing it cancels an in-flight drag.\n *\n * With groups, pass the **total** across groups: the number only has to change\n * whenever the indices the drag was based on stop being valid.\n */\n itemCount: number;\n /**\n * Called once, when a move is committed — on pointer release or on a keyboard\n * move. Never called mid-drag, so a controlled list re-renders once instead of\n * on every pointer frame.\n */\n onReorder: (target: SortableDropTarget) => void;\n /** Blocks all interaction. */\n disabled?: boolean;\n /**\n * Description announced for each item, so a screen reader user learns the\n * keyboard contract. Default is in English — override it to localize.\n */\n roleDescription?: string;\n}\n\nexport interface UseSortableResult {\n /** Index being dragged, or `null` when idle. */\n activeIndex: number | null;\n /** Index the item would land on, or `null` when idle. */\n overIndex: number | null;\n /** Group the dragged item came from, when using groups. */\n activeGroup?: string;\n /** Group the item is currently over, when using groups. */\n overGroup?: string;\n /**\n * Spread on each item. Pass `group` when the sortable spans several lists (a\n * Kanban column id), so a drop can land in a different group.\n */\n getItemProps: (index: number, group?: string) => SortableItemProps;\n /**\n * Spread on an **empty** group's drop area, so an item can be moved into a\n * column that has no cards yet — hit-testing works off item rects, and a group\n * with no items has none.\n */\n getEmptyGroupProps: (group: string) => {\n \"data-sortable-group\": string;\n \"data-sortable-empty\": true;\n };\n /** Spread on the list container (`role=\"listbox\"` + a label of your own). */\n getListProps: () => { role: \"listbox\"; \"aria-orientation\": \"vertical\" };\n /** Abort the current drag without reordering. */\n cancel: () => void;\n /**\n * Callback ref for the element that contains the sortable items — hit-testing\n * searches inside it.\n *\n * Named `setContainer` rather than `ref` because it is a **callback**, not a ref\n * object: `ref={sortable.setContainer}`. The old name also read as a readable\n * ref to the React Compiler rules, which flagged every consumer.\n */\n setContainer: (node: HTMLElement | null) => void;\n}\n\n/** Reorder helper: move `from` to `to`, returning a new array. */\nexport function moveItem<T>(items: T[], from: number, to: number): T[] {\n if (from === to || from < 0 || to < 0 || from >= items.length || to >= items.length) {\n return items;\n }\n const next = items.slice();\n const [moved] = next.splice(from, 1);\n next.splice(to, 0, moved);\n return next;\n}\n\n/** A hit-test result: the item (or empty group) under the pointer. */\ninterface HitTarget {\n index: number;\n group?: string;\n}\n\n/**\n * Find the sortable slot under a viewport point.\n *\n * Items are tried first; an empty group's drop area is only considered when no item\n * matched, since an empty column is a fallback target rather than a competing one.\n */\nfunction targetAtPoint(container: HTMLElement | null, x: number, y: number): HitTarget | null {\n if (!container) return null;\n const inside = (element: HTMLElement): boolean => {\n const rect = element.getBoundingClientRect();\n return y >= rect.top && y <= rect.bottom && x >= rect.left && x <= rect.right;\n };\n\n for (const item of Array.from(\n container.querySelectorAll<HTMLElement>(\"[data-sortable-index]\"),\n )) {\n if (!inside(item)) continue;\n const index = Number(item.dataset.sortableIndex);\n if (Number.isNaN(index)) return null;\n return { index, group: item.dataset.sortableGroup };\n }\n\n for (const empty of Array.from(\n container.querySelectorAll<HTMLElement>(\"[data-sortable-empty]\"),\n )) {\n if (inside(empty)) return { index: 0, group: empty.dataset.sortableGroup };\n }\n\n return null;\n}\n\n/**\n * Drag-to-reorder for a list, with a **keyboard path of equal standing**.\n *\n * Pointer events cover mouse, touch and stylus through one code path, and pointer\n * capture keeps the drag alive when the pointer leaves the item. `Space` picks an\n * item up, the arrows move it, `Space`/`Enter` drops it and `Escape` cancels —\n * because a reorder that only works by dragging excludes keyboard users\n * completely, which is where most drag-and-drop implementations fail.\n *\n * The hook owns interaction only: it never mutates your data. `onReorder` fires\n * once per committed move and you apply it, typically with {@link moveItem}. That\n * keeps the list a controlled value and avoids a re-render per pointer frame.\n *\n * Hit-testing reads the live DOM rects of `[data-sortable-index]` children rather\n * than assuming a fixed row height, so it works with rows of different sizes.\n *\n * A change in `itemCount` cancels an in-flight drag: the list no longer has the\n * indices the drag was based on, and committing anyway would move the wrong row.\n *\n * Note for consumers: the indices and groups are exposed as **state** (so rendering\n * from them is reactive) and mirrored into refs only so the window-level `pointerup`\n * listener can read both synchronously. Because the handlers returned by\n * `getItemProps` close over those mirrors, the React Compiler `refs` rule reports\n * the *call site* — a component spreading these props may need a file-level\n * exemption, as `Kanban` documents.\n *\n * @example\n * ```tsx\n * const [items, setItems] = useState([\"Alfa\", \"Bravo\", \"Charlie\"]);\n * const sortable = useSortable({\n * itemCount: items.length,\n * onReorder: ({ from, to }) => setItems((current) => moveItem(current, from, to)),\n * });\n *\n * <ul {...sortable.getListProps()} aria-label=\"Prioridade\" ref={sortable.setContainer}>\n * {items.map((item, index) => (\n * <li key={item} {...sortable.getItemProps(index)}>\n * {item}\n * </li>\n * ))}\n * </ul>\n * ```\n */\nexport function useSortable(options: UseSortableOptions): UseSortableResult {\n const { itemCount, onReorder, disabled = false, roleDescription = \"Sortable item\" } = options;\n\n const [activeIndex, setActiveIndexState] = useState<number | null>(null);\n const [overIndex, setOverIndexState] = useState<number | null>(null);\n const containerRef = useRef<HTMLElement | null>(null);\n const onReorderRef = useRef(onReorder);\n\n /**\n * Mirrors of the two indices.\n *\n * The window-level `pointerup` listener has to read both values *synchronously*\n * to know what to commit, and a state setter only exposes one of them. Mirroring\n * into refs keeps the commit readable instead of nesting setters to smuggle the\n * other value out.\n */\n const activeRef = useRef<number | null>(null);\n const overRef = useRef<number | null>(null);\n const [activeGroup, setActiveGroupState] = useState<string | undefined>(undefined);\n const [overGroup, setOverGroupState] = useState<string | undefined>(undefined);\n const activeGroupRef = useRef<string | undefined>(undefined);\n const overGroupRef = useRef<string | undefined>(undefined);\n\n const setActiveGroup = useCallback((value: string | undefined): void => {\n activeGroupRef.current = value;\n setActiveGroupState(value);\n }, []);\n\n const setOverGroup = useCallback((value: string | undefined): void => {\n overGroupRef.current = value;\n setOverGroupState(value);\n }, []);\n\n const setActiveIndex = useCallback((value: number | null): void => {\n activeRef.current = value;\n setActiveIndexState(value);\n }, []);\n\n const setOverIndex = useCallback((value: number | null): void => {\n overRef.current = value;\n setOverIndexState(value);\n }, []);\n\n useEffect(() => {\n onReorderRef.current = onReorder;\n }, [onReorder]);\n\n const reset = useCallback((): void => {\n setActiveGroup(undefined);\n setOverGroup(undefined);\n setActiveIndex(null);\n setOverIndex(null);\n }, [setActiveGroup, setActiveIndex, setOverGroup, setOverIndex]);\n\n useEffect(() => {\n reset();\n }, [itemCount, reset]);\n\n /**\n * Report a committed move and go back to idle.\n *\n * The groups are **arguments**, not reads of the mirror refs: `commit` ends up\n * inside the props returned by `getItemProps`, which render calls, and a ref read\n * anywhere in that closure chain is a render-time ref access. The pointer\n * listener reads the mirrors itself — it is an event handler, where reading is\n * fine — and hands the values over.\n *\n * A move that changes group counts even at the same index (the card lands in\n * another column), so only a same-group, same-index drop is \"nothing happened\".\n */\n const commit = useCallback(\n (from: number, to: number, fromGroup?: string, toGroup?: string): void => {\n if (from !== to || fromGroup !== toGroup) {\n onReorderRef.current({ from, to, fromGroup, toGroup });\n }\n reset();\n },\n [reset],\n );\n\n const setContainer = useCallback((node: HTMLElement | null): void => {\n containerRef.current = node;\n }, []);\n\n const handlePointerDown = useCallback(\n (index: number, group: string | undefined, event: ReactPointerEvent<HTMLElement>): void => {\n if (disabled) return;\n event.currentTarget.setPointerCapture?.(event.pointerId);\n setActiveGroup(group);\n setOverGroup(group);\n setActiveIndex(index);\n setOverIndex(index);\n },\n [disabled, setActiveGroup, setActiveIndex, setOverGroup, setOverIndex],\n );\n\n useEffect(() => {\n if (activeIndex === null) return;\n\n function onPointerMove(event: PointerEvent): void {\n const target = targetAtPoint(containerRef.current, event.clientX, event.clientY);\n if (!target) return;\n setOverGroup(target.group);\n setOverIndex(target.index);\n }\n\n function onPointerUp(): void {\n const from = activeRef.current;\n const to = overRef.current;\n if (from !== null && to !== null) {\n commit(from, to, activeGroupRef.current, overGroupRef.current);\n } else {\n reset();\n }\n }\n\n function onKeyDown(event: KeyboardEvent): void {\n if (event.key === \"Escape\") reset();\n }\n\n window.addEventListener(\"pointermove\", onPointerMove);\n window.addEventListener(\"pointerup\", onPointerUp);\n window.addEventListener(\"pointercancel\", reset);\n window.addEventListener(\"keydown\", onKeyDown);\n return () => {\n window.removeEventListener(\"pointermove\", onPointerMove);\n window.removeEventListener(\"pointerup\", onPointerUp);\n window.removeEventListener(\"pointercancel\", reset);\n window.removeEventListener(\"keydown\", onKeyDown);\n };\n }, [activeIndex, commit, reset, setOverGroup, setOverIndex]);\n\n const handleKeyDown = useCallback(\n (\n index: number,\n group: string | undefined,\n event: ReactKeyboardEvent<HTMLElement>,\n ): void => {\n if (disabled) return;\n const picked = activeIndex !== null;\n\n switch (event.key) {\n case \" \":\n case \"Enter\":\n event.preventDefault();\n if (!picked) {\n setActiveGroup(group);\n setOverGroup(group);\n setActiveIndex(index);\n setOverIndex(index);\n } else if (overIndex !== null) {\n commit(activeIndex, overIndex, activeGroup, overGroup);\n }\n break;\n case \"ArrowDown\":\n case \"ArrowRight\": {\n if (!picked) return;\n event.preventDefault();\n if (overRef.current !== null) {\n setOverIndex(Math.min(overRef.current + 1, itemCount - 1));\n }\n break;\n }\n case \"ArrowUp\":\n case \"ArrowLeft\": {\n if (!picked) return;\n event.preventDefault();\n if (overRef.current !== null) {\n setOverIndex(Math.max(overRef.current - 1, 0));\n }\n break;\n }\n case \"Escape\":\n if (!picked) return;\n event.preventDefault();\n reset();\n break;\n default:\n break;\n }\n },\n [\n activeGroup,\n activeIndex,\n commit,\n overGroup,\n disabled,\n itemCount,\n overIndex,\n reset,\n setActiveGroup,\n setActiveIndex,\n setOverGroup,\n setOverIndex,\n ],\n );\n\n const getItemProps = useCallback(\n (index: number, group?: string): SortableItemProps => ({\n \"data-sortable-index\": index,\n \"data-sortable-group\": group,\n \"data-sortable-active\": (activeIndex === index && activeGroup === group) || undefined,\n onPointerDown: (event) => handlePointerDown(index, group, event),\n onKeyDown: (event) => handleKeyDown(index, group, event),\n tabIndex: disabled ? -1 : 0,\n role: \"option\",\n \"aria-roledescription\": roleDescription,\n \"aria-selected\": activeIndex === index && activeGroup === group,\n }),\n [activeGroup, activeIndex, disabled, handleKeyDown, handlePointerDown, roleDescription],\n );\n\n const getEmptyGroupProps = useCallback(\n (group: string) => ({ \"data-sortable-group\": group, \"data-sortable-empty\": true as const }),\n [],\n );\n\n const getListProps = useCallback(\n () => ({ role: \"listbox\" as const, \"aria-orientation\": \"vertical\" as const }),\n [],\n );\n\n return {\n activeIndex,\n overIndex,\n activeGroup,\n overGroup,\n getItemProps,\n getEmptyGroupProps,\n getListProps,\n cancel: reset,\n setContainer,\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"use-sortable.js","names":[],"sources":["../../src/hooks/use-sortable.ts"],"sourcesContent":["/**\n * @tempest-limits file-lines, hook-lines — drag-and-drop with a keyboard path that\n * has to produce the same moves as the pointer path: pointer capture, auto-scroll\n * near the edges, the drop-index maths and the live-region announcements all read\n * one drag state. Two hooks would mean two copies of the index arithmetic, and the\n * two paths silently disagreeing is the bug this exists to prevent.\n */\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from \"react\";\n\n/** Where a dragged item would land if dropped now. */\nexport interface SortableDropTarget {\n /** Index the item started at, within its own group. */\n from: number;\n /** Index it would move to, within the destination group. */\n to: number;\n /**\n * Group the item started in — a Kanban column, for instance. `undefined` for a\n * plain single-list sortable.\n */\n fromGroup?: string;\n /** Destination group. Equal to `fromGroup` when the move stays in one group. */\n toGroup?: string;\n}\n\n/** Props to spread on each sortable item. */\nexport interface SortableItemProps {\n /** Marks the item for hit-testing. */\n \"data-sortable-index\": number;\n /** Group the item belongs to, when the sortable spans several lists. */\n \"data-sortable-group\"?: string;\n /** `true` while this item is the one being moved. */\n \"data-sortable-active\"?: boolean;\n /** Grab handle wiring: starts a pointer drag. */\n onPointerDown: (event: ReactPointerEvent<HTMLElement>) => void;\n /** Keyboard reordering — see {@link UseSortableResult}. */\n onKeyDown: (event: ReactKeyboardEvent<HTMLElement>) => void;\n /** Puts the item in the tab order so the keyboard path is reachable. */\n tabIndex: number;\n role: \"option\";\n \"aria-roledescription\": string;\n \"aria-selected\": boolean;\n}\n\nexport interface UseSortableOptions {\n /**\n * How many items are in the sortable. Changing it cancels an in-flight drag.\n *\n * With groups, pass the **total** across groups: the number only has to change\n * whenever the indices the drag was based on stop being valid.\n */\n itemCount: number;\n /**\n * Called once, when a move is committed — on pointer release or on a keyboard\n * move. Never called mid-drag, so a controlled list re-renders once instead of\n * on every pointer frame.\n */\n onReorder: (target: SortableDropTarget) => void;\n /** Blocks all interaction. */\n disabled?: boolean;\n /**\n * Description announced for each item, so a screen reader user learns the\n * keyboard contract. Default is in English — override it to localize.\n */\n roleDescription?: string;\n}\n\nexport interface UseSortableResult {\n /** Index being dragged, or `null` when idle. */\n activeIndex: number | null;\n /** Index the item would land on, or `null` when idle. */\n overIndex: number | null;\n /** Group the dragged item came from, when using groups. */\n activeGroup?: string;\n /** Group the item is currently over, when using groups. */\n overGroup?: string;\n /**\n * Spread on each item. Pass `group` when the sortable spans several lists (a\n * Kanban column id), so a drop can land in a different group.\n */\n getItemProps: (index: number, group?: string) => SortableItemProps;\n /**\n * Spread on an **empty** group's drop area, so an item can be moved into a\n * column that has no cards yet — hit-testing works off item rects, and a group\n * with no items has none.\n */\n getEmptyGroupProps: (group: string) => {\n \"data-sortable-group\": string;\n \"data-sortable-empty\": true;\n };\n /** Spread on the list container (`role=\"listbox\"` + a label of your own). */\n getListProps: () => { role: \"listbox\"; \"aria-orientation\": \"vertical\" };\n /** Abort the current drag without reordering. */\n cancel: () => void;\n /**\n * Callback ref for the element that contains the sortable items — hit-testing\n * searches inside it.\n *\n * Named `setContainer` rather than `ref` because it is a **callback**, not a ref\n * object: `ref={sortable.setContainer}`. The old name also read as a readable\n * ref to the React Compiler rules, which flagged every consumer.\n */\n setContainer: (node: HTMLElement | null) => void;\n}\n\n/** Reorder helper: move `from` to `to`, returning a new array. */\nexport function moveItem<T>(items: T[], from: number, to: number): T[] {\n if (from === to || from < 0 || to < 0 || from >= items.length || to >= items.length) {\n return items;\n }\n const next = items.slice();\n const [moved] = next.splice(from, 1);\n next.splice(to, 0, moved);\n return next;\n}\n\n/** A hit-test result: the item (or empty group) under the pointer. */\ninterface HitTarget {\n index: number;\n group?: string;\n}\n\n/**\n * Find the sortable slot under a viewport point.\n *\n * Items are tried first; an empty group's drop area is only considered when no item\n * matched, since an empty column is a fallback target rather than a competing one.\n */\nfunction targetAtPoint(container: HTMLElement | null, x: number, y: number): HitTarget | null {\n if (!container) return null;\n const inside = (element: HTMLElement): boolean => {\n const rect = element.getBoundingClientRect();\n return y >= rect.top && y <= rect.bottom && x >= rect.left && x <= rect.right;\n };\n\n for (const item of Array.from(\n container.querySelectorAll<HTMLElement>(\"[data-sortable-index]\"),\n )) {\n if (!inside(item)) continue;\n const index = Number(item.dataset.sortableIndex);\n if (Number.isNaN(index)) return null;\n return { index, group: item.dataset.sortableGroup };\n }\n\n for (const empty of Array.from(\n container.querySelectorAll<HTMLElement>(\"[data-sortable-empty]\"),\n )) {\n if (inside(empty)) return { index: 0, group: empty.dataset.sortableGroup };\n }\n\n return null;\n}\n\n/**\n * Drag-to-reorder for a list, with a **keyboard path of equal standing**.\n *\n * Pointer events cover mouse, touch and stylus through one code path, and pointer\n * capture keeps the drag alive when the pointer leaves the item. `Space` picks an\n * item up, the arrows move it, `Space`/`Enter` drops it and `Escape` cancels —\n * because a reorder that only works by dragging excludes keyboard users\n * completely, which is where most drag-and-drop implementations fail.\n *\n * The hook owns interaction only: it never mutates your data. `onReorder` fires\n * once per committed move and you apply it, typically with {@link moveItem}. That\n * keeps the list a controlled value and avoids a re-render per pointer frame.\n *\n * Hit-testing reads the live DOM rects of `[data-sortable-index]` children rather\n * than assuming a fixed row height, so it works with rows of different sizes.\n *\n * A change in `itemCount` cancels an in-flight drag: the list no longer has the\n * indices the drag was based on, and committing anyway would move the wrong row.\n *\n * Note for consumers: the indices and groups are exposed as **state** (so rendering\n * from them is reactive) and mirrored into refs only so the window-level `pointerup`\n * listener can read both synchronously. Because the handlers returned by\n * `getItemProps` close over those mirrors, the React Compiler `refs` rule reports\n * the *call site* — a component spreading these props may need a file-level\n * exemption, as `Kanban` documents.\n *\n * @example\n * ```tsx\n * const [items, setItems] = useState([\"Alfa\", \"Bravo\", \"Charlie\"]);\n * const sortable = useSortable({\n * itemCount: items.length,\n * onReorder: ({ from, to }) => setItems((current) => moveItem(current, from, to)),\n * });\n *\n * <ul {...sortable.getListProps()} aria-label=\"Prioridade\" ref={sortable.setContainer}>\n * {items.map((item, index) => (\n * <li key={item} {...sortable.getItemProps(index)}>\n * {item}\n * </li>\n * ))}\n * </ul>\n * ```\n */\nexport function useSortable(options: UseSortableOptions): UseSortableResult {\n const { itemCount, onReorder, disabled = false, roleDescription = \"Sortable item\" } = options;\n\n const [activeIndex, setActiveIndexState] = useState<number | null>(null);\n const [overIndex, setOverIndexState] = useState<number | null>(null);\n const containerRef = useRef<HTMLElement | null>(null);\n const onReorderRef = useRef(onReorder);\n\n /**\n * Mirrors of the two indices.\n *\n * The window-level `pointerup` listener has to read both values *synchronously*\n * to know what to commit, and a state setter only exposes one of them. Mirroring\n * into refs keeps the commit readable instead of nesting setters to smuggle the\n * other value out.\n */\n const activeRef = useRef<number | null>(null);\n const overRef = useRef<number | null>(null);\n const [activeGroup, setActiveGroupState] = useState<string | undefined>(undefined);\n const [overGroup, setOverGroupState] = useState<string | undefined>(undefined);\n const activeGroupRef = useRef<string | undefined>(undefined);\n const overGroupRef = useRef<string | undefined>(undefined);\n\n const setActiveGroup = useCallback((value: string | undefined): void => {\n activeGroupRef.current = value;\n setActiveGroupState(value);\n }, []);\n\n const setOverGroup = useCallback((value: string | undefined): void => {\n overGroupRef.current = value;\n setOverGroupState(value);\n }, []);\n\n const setActiveIndex = useCallback((value: number | null): void => {\n activeRef.current = value;\n setActiveIndexState(value);\n }, []);\n\n const setOverIndex = useCallback((value: number | null): void => {\n overRef.current = value;\n setOverIndexState(value);\n }, []);\n\n useEffect(() => {\n onReorderRef.current = onReorder;\n }, [onReorder]);\n\n const reset = useCallback((): void => {\n setActiveGroup(undefined);\n setOverGroup(undefined);\n setActiveIndex(null);\n setOverIndex(null);\n }, [setActiveGroup, setActiveIndex, setOverGroup, setOverIndex]);\n\n useEffect(() => {\n reset();\n }, [itemCount, reset]);\n\n /**\n * Report a committed move and go back to idle.\n *\n * The groups are **arguments**, not reads of the mirror refs: `commit` ends up\n * inside the props returned by `getItemProps`, which render calls, and a ref read\n * anywhere in that closure chain is a render-time ref access. The pointer\n * listener reads the mirrors itself — it is an event handler, where reading is\n * fine — and hands the values over.\n *\n * A move that changes group counts even at the same index (the card lands in\n * another column), so only a same-group, same-index drop is \"nothing happened\".\n */\n const commit = useCallback(\n (from: number, to: number, fromGroup?: string, toGroup?: string): void => {\n if (from !== to || fromGroup !== toGroup) {\n onReorderRef.current({ from, to, fromGroup, toGroup });\n }\n reset();\n },\n [reset],\n );\n\n const setContainer = useCallback((node: HTMLElement | null): void => {\n containerRef.current = node;\n }, []);\n\n const handlePointerDown = useCallback(\n (index: number, group: string | undefined, event: ReactPointerEvent<HTMLElement>): void => {\n if (disabled) return;\n event.currentTarget.setPointerCapture?.(event.pointerId);\n setActiveGroup(group);\n setOverGroup(group);\n setActiveIndex(index);\n setOverIndex(index);\n },\n [disabled, setActiveGroup, setActiveIndex, setOverGroup, setOverIndex],\n );\n\n useEffect(() => {\n if (activeIndex === null) return;\n\n function onPointerMove(event: PointerEvent): void {\n const target = targetAtPoint(containerRef.current, event.clientX, event.clientY);\n if (!target) return;\n setOverGroup(target.group);\n setOverIndex(target.index);\n }\n\n function onPointerUp(): void {\n const from = activeRef.current;\n const to = overRef.current;\n if (from !== null && to !== null) {\n commit(from, to, activeGroupRef.current, overGroupRef.current);\n } else {\n reset();\n }\n }\n\n function onKeyDown(event: KeyboardEvent): void {\n if (event.key === \"Escape\") reset();\n }\n\n window.addEventListener(\"pointermove\", onPointerMove);\n window.addEventListener(\"pointerup\", onPointerUp);\n window.addEventListener(\"pointercancel\", reset);\n window.addEventListener(\"keydown\", onKeyDown);\n return () => {\n window.removeEventListener(\"pointermove\", onPointerMove);\n window.removeEventListener(\"pointerup\", onPointerUp);\n window.removeEventListener(\"pointercancel\", reset);\n window.removeEventListener(\"keydown\", onKeyDown);\n };\n }, [activeIndex, commit, reset, setOverGroup, setOverIndex]);\n\n const handleKeyDown = useCallback(\n (\n index: number,\n group: string | undefined,\n event: ReactKeyboardEvent<HTMLElement>,\n ): void => {\n if (disabled) return;\n const picked = activeIndex !== null;\n\n switch (event.key) {\n case \" \":\n case \"Enter\":\n event.preventDefault();\n if (!picked) {\n setActiveGroup(group);\n setOverGroup(group);\n setActiveIndex(index);\n setOverIndex(index);\n } else if (overIndex !== null) {\n commit(activeIndex, overIndex, activeGroup, overGroup);\n }\n break;\n case \"ArrowDown\":\n case \"ArrowRight\": {\n if (!picked) return;\n event.preventDefault();\n if (overRef.current !== null) {\n setOverIndex(Math.min(overRef.current + 1, itemCount - 1));\n }\n break;\n }\n case \"ArrowUp\":\n case \"ArrowLeft\": {\n if (!picked) return;\n event.preventDefault();\n if (overRef.current !== null) {\n setOverIndex(Math.max(overRef.current - 1, 0));\n }\n break;\n }\n case \"Escape\":\n if (!picked) return;\n event.preventDefault();\n reset();\n break;\n default:\n break;\n }\n },\n [\n activeGroup,\n activeIndex,\n commit,\n overGroup,\n disabled,\n itemCount,\n overIndex,\n reset,\n setActiveGroup,\n setActiveIndex,\n setOverGroup,\n setOverIndex,\n ],\n );\n\n const getItemProps = useCallback(\n (index: number, group?: string): SortableItemProps => ({\n \"data-sortable-index\": index,\n \"data-sortable-group\": group,\n \"data-sortable-active\": (activeIndex === index && activeGroup === group) || undefined,\n onPointerDown: (event) => handlePointerDown(index, group, event),\n onKeyDown: (event) => handleKeyDown(index, group, event),\n tabIndex: disabled ? -1 : 0,\n role: \"option\",\n \"aria-roledescription\": roleDescription,\n \"aria-selected\": activeIndex === index && activeGroup === group,\n }),\n [activeGroup, activeIndex, disabled, handleKeyDown, handlePointerDown, roleDescription],\n );\n\n const getEmptyGroupProps = useCallback(\n (group: string) => ({ \"data-sortable-group\": group, \"data-sortable-empty\": true as const }),\n [],\n );\n\n const getListProps = useCallback(\n () => ({ role: \"listbox\" as const, \"aria-orientation\": \"vertical\" as const }),\n [],\n );\n\n return {\n activeIndex,\n overIndex,\n activeGroup,\n overGroup,\n getItemProps,\n getEmptyGroupProps,\n getListProps,\n cancel: reset,\n setContainer,\n };\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api-client.cjs","names":[],"sources":["../../src/http/api-client.ts"],"sourcesContent":["
|
|
1
|
+
{"version":3,"file":"api-client.cjs","names":[],"sources":["../../src/http/api-client.ts"],"sourcesContent":["/**\n * @tempest-limits function-lines — createApiClient is three lines over the limit and\n * every one of them is a request-lifecycle concern the client cannot delegate: base\n * URL joining, the auth header, the retry loop, the timeout signal and the response\n * parsing that turns a failure into a typed error.\n */\nimport { randomId } from \"../utils\";\nimport { buildApiError, TempestApiError } from \"./errors\";\nimport type { ApiClient, ApiClientConfig, RequestOptions } from \"./types\";\n\nfunction buildUrl(baseURL: string, path: string, params?: RequestOptions[\"params\"]): string {\n const url = new URL(path, baseURL.endsWith(\"/\") ? baseURL : `${baseURL}/`);\n if (params) {\n for (const [key, value] of Object.entries(params)) {\n if (value !== undefined && value !== null) {\n url.searchParams.set(key, String(value));\n }\n }\n }\n return url.toString();\n}\n\nfunction isFormData(body: unknown): body is FormData {\n return typeof FormData !== \"undefined\" && body instanceof FormData;\n}\n\nasync function parseError(response: Response, sentRequestId?: string): Promise<TempestApiError> {\n let body: unknown;\n try {\n body = await response.clone().json();\n } catch {\n try {\n body = await response.text();\n } catch {\n body = null;\n }\n }\n return new TempestApiError(\n buildApiError(response.status, body, response.headers, sentRequestId),\n );\n}\n\n/**\n * Create a typed HTTP client backed by `fetch`.\n *\n * Handles JSON serialization, query params, bearer auth via `getToken`,\n * automatic refresh + retry on 401 when `refresh` is supplied, and uploads\n * via `FormData`. Throws an `ApiError` on non-2xx responses.\n */\nexport function createApiClient(config: ApiClientConfig): ApiClient {\n const fetcher = config.fetcher ?? globalThis.fetch.bind(globalThis);\n\n function authHeaders(): Record<string, string> {\n const token = config.getToken?.();\n return token ? { Authorization: `Bearer ${token}` } : {};\n }\n\n async function rawRequest(\n path: string,\n options: RequestOptions,\n requestId?: string,\n ): Promise<Response> {\n const { body, params, headers, ...rest } = options;\n const isForm = isFormData(body);\n\n const finalHeaders: Record<string, string> = {\n ...(isForm ? {} : { \"Content-Type\": \"application/json\" }),\n ...(requestId ? { \"X-Request-ID\": requestId } : {}),\n ...config.headers,\n ...authHeaders(),\n ...(headers as Record<string, string> | undefined),\n };\n\n const init: RequestInit = {\n ...rest,\n headers: finalHeaders,\n credentials: config.withCredentials ? \"include\" : rest.credentials,\n body:\n body === undefined || body === null\n ? undefined\n : isForm\n ? (body as FormData)\n : JSON.stringify(body),\n };\n\n return fetcher(buildUrl(config.baseURL, path, params), init);\n }\n\n async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {\n const requestId = config.requestId ? config.requestId() : randomId();\n let response = await rawRequest(path, options, requestId);\n\n if (response.status === 401) {\n if (config.refresh) {\n try {\n await config.refresh();\n response = await rawRequest(path, options, requestId);\n } catch {\n await config.onUnauthorized?.(response);\n throw await parseError(response, requestId);\n }\n } else {\n await config.onUnauthorized?.(response);\n }\n }\n\n if (!response.ok) {\n throw await parseError(response, requestId);\n }\n\n if (response.status === 204) {\n return undefined as T;\n }\n\n const contentType = response.headers.get(\"content-type\") ?? \"\";\n if (contentType.includes(\"application/json\")) {\n return (await response.json()) as T;\n }\n return (await response.text()) as unknown as T;\n }\n\n async function upload<T>(\n path: string,\n formData: FormData,\n method: \"POST\" | \"PUT\" | \"PATCH\" = \"POST\",\n ): Promise<T> {\n return request<T>(path, { method, body: formData });\n }\n\n return {\n request,\n get: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"GET\" }),\n post: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"POST\" }),\n put: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"PUT\" }),\n patch: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"PATCH\" }),\n delete: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"DELETE\" }),\n upload,\n };\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api-client.js","names":[],"sources":["../../src/http/api-client.ts"],"sourcesContent":["
|
|
1
|
+
{"version":3,"file":"api-client.js","names":[],"sources":["../../src/http/api-client.ts"],"sourcesContent":["/**\n * @tempest-limits function-lines — createApiClient is three lines over the limit and\n * every one of them is a request-lifecycle concern the client cannot delegate: base\n * URL joining, the auth header, the retry loop, the timeout signal and the response\n * parsing that turns a failure into a typed error.\n */\nimport { randomId } from \"../utils\";\nimport { buildApiError, TempestApiError } from \"./errors\";\nimport type { ApiClient, ApiClientConfig, RequestOptions } from \"./types\";\n\nfunction buildUrl(baseURL: string, path: string, params?: RequestOptions[\"params\"]): string {\n const url = new URL(path, baseURL.endsWith(\"/\") ? baseURL : `${baseURL}/`);\n if (params) {\n for (const [key, value] of Object.entries(params)) {\n if (value !== undefined && value !== null) {\n url.searchParams.set(key, String(value));\n }\n }\n }\n return url.toString();\n}\n\nfunction isFormData(body: unknown): body is FormData {\n return typeof FormData !== \"undefined\" && body instanceof FormData;\n}\n\nasync function parseError(response: Response, sentRequestId?: string): Promise<TempestApiError> {\n let body: unknown;\n try {\n body = await response.clone().json();\n } catch {\n try {\n body = await response.text();\n } catch {\n body = null;\n }\n }\n return new TempestApiError(\n buildApiError(response.status, body, response.headers, sentRequestId),\n );\n}\n\n/**\n * Create a typed HTTP client backed by `fetch`.\n *\n * Handles JSON serialization, query params, bearer auth via `getToken`,\n * automatic refresh + retry on 401 when `refresh` is supplied, and uploads\n * via `FormData`. Throws an `ApiError` on non-2xx responses.\n */\nexport function createApiClient(config: ApiClientConfig): ApiClient {\n const fetcher = config.fetcher ?? globalThis.fetch.bind(globalThis);\n\n function authHeaders(): Record<string, string> {\n const token = config.getToken?.();\n return token ? { Authorization: `Bearer ${token}` } : {};\n }\n\n async function rawRequest(\n path: string,\n options: RequestOptions,\n requestId?: string,\n ): Promise<Response> {\n const { body, params, headers, ...rest } = options;\n const isForm = isFormData(body);\n\n const finalHeaders: Record<string, string> = {\n ...(isForm ? {} : { \"Content-Type\": \"application/json\" }),\n ...(requestId ? { \"X-Request-ID\": requestId } : {}),\n ...config.headers,\n ...authHeaders(),\n ...(headers as Record<string, string> | undefined),\n };\n\n const init: RequestInit = {\n ...rest,\n headers: finalHeaders,\n credentials: config.withCredentials ? \"include\" : rest.credentials,\n body:\n body === undefined || body === null\n ? undefined\n : isForm\n ? (body as FormData)\n : JSON.stringify(body),\n };\n\n return fetcher(buildUrl(config.baseURL, path, params), init);\n }\n\n async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {\n const requestId = config.requestId ? config.requestId() : randomId();\n let response = await rawRequest(path, options, requestId);\n\n if (response.status === 401) {\n if (config.refresh) {\n try {\n await config.refresh();\n response = await rawRequest(path, options, requestId);\n } catch {\n await config.onUnauthorized?.(response);\n throw await parseError(response, requestId);\n }\n } else {\n await config.onUnauthorized?.(response);\n }\n }\n\n if (!response.ok) {\n throw await parseError(response, requestId);\n }\n\n if (response.status === 204) {\n return undefined as T;\n }\n\n const contentType = response.headers.get(\"content-type\") ?? \"\";\n if (contentType.includes(\"application/json\")) {\n return (await response.json()) as T;\n }\n return (await response.text()) as unknown as T;\n }\n\n async function upload<T>(\n path: string,\n formData: FormData,\n method: \"POST\" | \"PUT\" | \"PATCH\" = \"POST\",\n ): Promise<T> {\n return request<T>(path, { method, body: formData });\n }\n\n return {\n request,\n get: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"GET\" }),\n post: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"POST\" }),\n put: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"PUT\" }),\n patch: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"PATCH\" }),\n delete: <T>(path: string, options?: RequestOptions) =>\n request<T>(path, { ...options, method: \"DELETE\" }),\n upload,\n };\n}\n"],"mappings":"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"}
|
package/dist/http/errors.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.cjs","names":[],"sources":["../../src/http/errors.ts"],"sourcesContent":["import type { ApiError } from \"./types\";\n\n/**\n * Error thrown by {@link createApiClient} / {@link uploadWithProgress} on a\n * non-2xx response. Mirrors the Tempest FastAPI SDK error envelope\n * (`{ detail, code, details.request_id }`) so callers get a typed `code` and a\n * `requestId` for log correlation, while still being a real `Error` (stack\n * trace, `instanceof Error`).\n *\n * @example\n * try {\n * await api.post(\"/users\", { body });\n * } catch (err) {\n * if (isApiError(err) && err.code === \"EMAIL_TAKEN\") {\n * showFieldError(\"email\", err.detail);\n * }\n * }\n */\nexport class TempestApiError extends Error implements ApiError {\n readonly status: number;\n readonly detail: string;\n readonly code?: string;\n readonly requestId?: string;\n readonly body?: unknown;\n\n constructor(init: ApiError) {\n super(init.detail);\n this.name = \"TempestApiError\";\n this.status = init.status;\n this.detail = init.detail;\n this.code = init.code;\n this.requestId = init.requestId;\n this.body = init.body;\n }\n}\n\n/**\n * Type guard for the {@link ApiError} shape. Matches both {@link TempestApiError}\n * instances and plain objects carrying `status` + `detail`.\n *\n * @param error - The unknown value (typically a caught error).\n * @returns Whether `error` conforms to the `ApiError` contract.\n */\nexport function isApiError(error: unknown): error is ApiError {\n return (\n typeof error === \"object\" &&\n error !== null &&\n typeof (error as ApiError).status === \"number\" &&\n typeof (error as ApiError).detail === \"string\"\n );\n}\n\n/**\n * Parse an error body + response into the Tempest {@link ApiError} envelope.\n *\n * Reads `detail`/`message`, the programmatic `code`, and the correlation id\n * from `details.request_id` (falling back to the `X-Request-ID` header, then\n * the id the client sent).\n *\n * @param status - HTTP status code.\n * @param body - The parsed error body (object, string, or null).\n * @param headers - The response headers (for the `X-Request-ID` fallback).\n * @param sentRequestId - The id the client sent on the request, if any.\n * @returns A fully-populated `ApiError`.\n */\nexport function buildApiError(\n status: number,\n body: unknown,\n headers?: Headers | { get(name: string): string | null },\n sentRequestId?: string,\n): ApiError {\n const obj =\n typeof body === \"object\" && body !== null ? (body as Record<string, unknown>) : null;\n const detail = obj?.detail ?? obj?.message ?? `Erro ${status}`;\n const code = typeof obj?.code === \"string\" ? obj.code : undefined;\n const details =\n typeof obj?.details === \"object\" && obj.details !== null\n ? (obj.details as Record<string, unknown>)\n : null;\n const requestId =\n (typeof details?.request_id === \"string\" ? details.request_id : undefined) ??\n headers?.get(\"X-Request-ID\") ??\n sentRequestId ??\n undefined;\n\n return {\n status,\n detail: String(detail),\n code,\n requestId: requestId ?? undefined,\n retryAfter: parseRetryAfter(headers?.get(\"Retry-After\")),\n body,\n };\n}\n\n/**\n * Parse a `Retry-After` header into seconds. Accepts a delta-seconds integer\n * (`\"120\"`) or an HTTP-date (`\"Wed, 21 Oct 2015 07:28:00 GMT\"`).\n *\n * @param value - The raw header value, or null.\n * @returns The delay in seconds (>= 0), or undefined when absent/unparseable.\n */\nexport function parseRetryAfter(value: string | null | undefined): number | undefined {\n if (!value) return undefined;\n const trimmed = value.trim();\n if (/^\\d+$/.test(trimmed)) return Number(trimmed);\n const when = Date.parse(trimmed);\n if (Number.isNaN(when)) return undefined;\n return Math.max(0, Math.round((when - Date.now()) / 1000));\n}\n"],"mappings":"AAkBA,IAAa,EAAb,cAAqC,KAA0B,CAC3D,OACA,OACA,KACA,UACA,KAEA,YAAY,EAAgB,CACxB,MAAM,EAAK,MAAM,EACjB,KAAK,KAAO,kBACZ,KAAK,OAAS,EAAK,OACnB,KAAK,OAAS,EAAK,OACnB,KAAK,KAAO,EAAK,KACjB,KAAK,UAAY,EAAK,UACtB,KAAK,KAAO,EAAK,IACrB,CACJ,EASA,SAAgB,EAAW,EAAmC,CAC1D,OACI,OAAO,GAAU,YACjB,GACA,OAAQ,EAAmB,QAAW,UACtC,OAAQ,EAAmB,QAAW,QAE9C,
|
|
1
|
+
{"version":3,"file":"errors.cjs","names":[],"sources":["../../src/http/errors.ts"],"sourcesContent":["import type { ApiError } from \"./types\";\n\n/**\n * Error thrown by {@link createApiClient} / {@link uploadWithProgress} on a\n * non-2xx response. Mirrors the Tempest FastAPI SDK error envelope\n * (`{ detail, code, details.request_id }`) so callers get a typed `code` and a\n * `requestId` for log correlation, while still being a real `Error` (stack\n * trace, `instanceof Error`).\n *\n * @example\n * try {\n * await api.post(\"/users\", { body });\n * } catch (err) {\n * if (isApiError(err) && err.code === \"EMAIL_TAKEN\") {\n * showFieldError(\"email\", err.detail);\n * }\n * }\n */\nexport class TempestApiError extends Error implements ApiError {\n readonly status: number;\n readonly detail: string;\n readonly code?: string;\n readonly requestId?: string;\n readonly body?: unknown;\n\n constructor(init: ApiError) {\n super(init.detail);\n this.name = \"TempestApiError\";\n this.status = init.status;\n this.detail = init.detail;\n this.code = init.code;\n this.requestId = init.requestId;\n this.body = init.body;\n }\n}\n\n/**\n * Type guard for the {@link ApiError} shape. Matches both {@link TempestApiError}\n * instances and plain objects carrying `status` + `detail`.\n *\n * @param error - The unknown value (typically a caught error).\n * @returns Whether `error` conforms to the `ApiError` contract.\n */\nexport function isApiError(error: unknown): error is ApiError {\n return (\n typeof error === \"object\" &&\n error !== null &&\n typeof (error as ApiError).status === \"number\" &&\n typeof (error as ApiError).detail === \"string\"\n );\n}\n\n/**\n * Parse an error body + response into the Tempest {@link ApiError} envelope.\n *\n * Reads `detail`/`message`, the programmatic `code`, and the correlation id\n * from `details.request_id` (falling back to the `X-Request-ID` header, then\n * the id the client sent).\n *\n * @param status - HTTP status code.\n * @param body - The parsed error body (object, string, or null).\n * @param headers - The response headers (for the `X-Request-ID` fallback).\n * @param sentRequestId - The id the client sent on the request, if any.\n * @returns A fully-populated `ApiError`.\n *\n * @tempest-limits param-count — the arguments are the response as it arrives\n * (`status`, `body`, `headers`) plus the id the request was sent with, and they are\n * passed at exactly one place: the client's response path. Exported from the package\n * root, so the rewrite would be breaking for callers that build their own errors.\n */\nexport function buildApiError(\n status: number,\n body: unknown,\n headers?: Headers | { get(name: string): string | null },\n sentRequestId?: string,\n): ApiError {\n const obj =\n typeof body === \"object\" && body !== null ? (body as Record<string, unknown>) : null;\n const detail = obj?.detail ?? obj?.message ?? `Erro ${status}`;\n const code = typeof obj?.code === \"string\" ? obj.code : undefined;\n const details =\n typeof obj?.details === \"object\" && obj.details !== null\n ? (obj.details as Record<string, unknown>)\n : null;\n const requestId =\n (typeof details?.request_id === \"string\" ? details.request_id : undefined) ??\n headers?.get(\"X-Request-ID\") ??\n sentRequestId ??\n undefined;\n\n return {\n status,\n detail: String(detail),\n code,\n requestId: requestId ?? undefined,\n retryAfter: parseRetryAfter(headers?.get(\"Retry-After\")),\n body,\n };\n}\n\n/**\n * Parse a `Retry-After` header into seconds. Accepts a delta-seconds integer\n * (`\"120\"`) or an HTTP-date (`\"Wed, 21 Oct 2015 07:28:00 GMT\"`).\n *\n * @param value - The raw header value, or null.\n * @returns The delay in seconds (>= 0), or undefined when absent/unparseable.\n */\nexport function parseRetryAfter(value: string | null | undefined): number | undefined {\n if (!value) return undefined;\n const trimmed = value.trim();\n if (/^\\d+$/.test(trimmed)) return Number(trimmed);\n const when = Date.parse(trimmed);\n if (Number.isNaN(when)) return undefined;\n return Math.max(0, Math.round((when - Date.now()) / 1000));\n}\n"],"mappings":"AAkBA,IAAa,EAAb,cAAqC,KAA0B,CAC3D,OACA,OACA,KACA,UACA,KAEA,YAAY,EAAgB,CACxB,MAAM,EAAK,MAAM,EACjB,KAAK,KAAO,kBACZ,KAAK,OAAS,EAAK,OACnB,KAAK,OAAS,EAAK,OACnB,KAAK,KAAO,EAAK,KACjB,KAAK,UAAY,EAAK,UACtB,KAAK,KAAO,EAAK,IACrB,CACJ,EASA,SAAgB,EAAW,EAAmC,CAC1D,OACI,OAAO,GAAU,YACjB,GACA,OAAQ,EAAmB,QAAW,UACtC,OAAQ,EAAmB,QAAW,QAE9C,CAoBA,SAAgB,EACZ,EACA,EACA,EACA,EACQ,CACR,IAAM,EACF,OAAO,GAAS,UAAY,EAAiB,EAAmC,KAC9E,EAAS,GAAK,QAAU,GAAK,SAAW,QAAQ,IAChD,EAAO,OAAO,GAAK,MAAS,SAAW,EAAI,KAAO,IAAA,GAClD,EACF,OAAO,GAAK,SAAY,UAAY,EAAI,UAAY,KAC7C,EAAI,QACL,KACJ,GACD,OAAO,GAAS,YAAe,SAAW,EAAQ,WAAa,IAAA,KAChE,GAAS,IAAI,cAAc,GAC3B,GACA,IAAA,GAEJ,MAAO,CACH,SACA,OAAQ,OAAO,CAAM,EACrB,OACA,UAAW,GAAa,IAAA,GACxB,WAAY,EAAgB,GAAS,IAAI,aAAa,CAAC,EACvD,MACJ,CACJ,CASA,SAAgB,EAAgB,EAAsD,CAClF,GAAI,CAAC,EAAO,OACZ,IAAM,EAAU,EAAM,KAAK,EAC3B,GAAI,QAAQ,KAAK,CAAO,EAAG,OAAO,OAAO,CAAO,EAChD,IAAM,EAAO,KAAK,MAAM,CAAO,EAC3B,WAAO,MAAM,CAAI,EACrB,OAAO,KAAK,IAAI,EAAG,KAAK,OAAO,EAAO,KAAK,IAAI,GAAK,GAAI,CAAC,CAC7D"}
|
package/dist/http/errors.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.js","names":[],"sources":["../../src/http/errors.ts"],"sourcesContent":["import type { ApiError } from \"./types\";\n\n/**\n * Error thrown by {@link createApiClient} / {@link uploadWithProgress} on a\n * non-2xx response. Mirrors the Tempest FastAPI SDK error envelope\n * (`{ detail, code, details.request_id }`) so callers get a typed `code` and a\n * `requestId` for log correlation, while still being a real `Error` (stack\n * trace, `instanceof Error`).\n *\n * @example\n * try {\n * await api.post(\"/users\", { body });\n * } catch (err) {\n * if (isApiError(err) && err.code === \"EMAIL_TAKEN\") {\n * showFieldError(\"email\", err.detail);\n * }\n * }\n */\nexport class TempestApiError extends Error implements ApiError {\n readonly status: number;\n readonly detail: string;\n readonly code?: string;\n readonly requestId?: string;\n readonly body?: unknown;\n\n constructor(init: ApiError) {\n super(init.detail);\n this.name = \"TempestApiError\";\n this.status = init.status;\n this.detail = init.detail;\n this.code = init.code;\n this.requestId = init.requestId;\n this.body = init.body;\n }\n}\n\n/**\n * Type guard for the {@link ApiError} shape. Matches both {@link TempestApiError}\n * instances and plain objects carrying `status` + `detail`.\n *\n * @param error - The unknown value (typically a caught error).\n * @returns Whether `error` conforms to the `ApiError` contract.\n */\nexport function isApiError(error: unknown): error is ApiError {\n return (\n typeof error === \"object\" &&\n error !== null &&\n typeof (error as ApiError).status === \"number\" &&\n typeof (error as ApiError).detail === \"string\"\n );\n}\n\n/**\n * Parse an error body + response into the Tempest {@link ApiError} envelope.\n *\n * Reads `detail`/`message`, the programmatic `code`, and the correlation id\n * from `details.request_id` (falling back to the `X-Request-ID` header, then\n * the id the client sent).\n *\n * @param status - HTTP status code.\n * @param body - The parsed error body (object, string, or null).\n * @param headers - The response headers (for the `X-Request-ID` fallback).\n * @param sentRequestId - The id the client sent on the request, if any.\n * @returns A fully-populated `ApiError`.\n */\nexport function buildApiError(\n status: number,\n body: unknown,\n headers?: Headers | { get(name: string): string | null },\n sentRequestId?: string,\n): ApiError {\n const obj =\n typeof body === \"object\" && body !== null ? (body as Record<string, unknown>) : null;\n const detail = obj?.detail ?? obj?.message ?? `Erro ${status}`;\n const code = typeof obj?.code === \"string\" ? obj.code : undefined;\n const details =\n typeof obj?.details === \"object\" && obj.details !== null\n ? (obj.details as Record<string, unknown>)\n : null;\n const requestId =\n (typeof details?.request_id === \"string\" ? details.request_id : undefined) ??\n headers?.get(\"X-Request-ID\") ??\n sentRequestId ??\n undefined;\n\n return {\n status,\n detail: String(detail),\n code,\n requestId: requestId ?? undefined,\n retryAfter: parseRetryAfter(headers?.get(\"Retry-After\")),\n body,\n };\n}\n\n/**\n * Parse a `Retry-After` header into seconds. Accepts a delta-seconds integer\n * (`\"120\"`) or an HTTP-date (`\"Wed, 21 Oct 2015 07:28:00 GMT\"`).\n *\n * @param value - The raw header value, or null.\n * @returns The delay in seconds (>= 0), or undefined when absent/unparseable.\n */\nexport function parseRetryAfter(value: string | null | undefined): number | undefined {\n if (!value) return undefined;\n const trimmed = value.trim();\n if (/^\\d+$/.test(trimmed)) return Number(trimmed);\n const when = Date.parse(trimmed);\n if (Number.isNaN(when)) return undefined;\n return Math.max(0, Math.round((when - Date.now()) / 1000));\n}\n"],"mappings":";AAkBA,IAAa,IAAb,cAAqC,MAA0B;CAC3D;CACA;CACA;CACA;CACA;CAEA,YAAY,GAAgB;EAOxB,AANA,MAAM,EAAK,MAAM,GACjB,KAAK,OAAO,mBACZ,KAAK,SAAS,EAAK,QACnB,KAAK,SAAS,EAAK,QACnB,KAAK,OAAO,EAAK,MACjB,KAAK,YAAY,EAAK,WACtB,KAAK,OAAO,EAAK;CACrB;AACJ;AASA,SAAgB,EAAW,GAAmC;CAC1D,OACI,OAAO,KAAU,cACjB,KACA,OAAQ,EAAmB,UAAW,YACtC,OAAQ,EAAmB,UAAW;AAE9C;
|
|
1
|
+
{"version":3,"file":"errors.js","names":[],"sources":["../../src/http/errors.ts"],"sourcesContent":["import type { ApiError } from \"./types\";\n\n/**\n * Error thrown by {@link createApiClient} / {@link uploadWithProgress} on a\n * non-2xx response. Mirrors the Tempest FastAPI SDK error envelope\n * (`{ detail, code, details.request_id }`) so callers get a typed `code` and a\n * `requestId` for log correlation, while still being a real `Error` (stack\n * trace, `instanceof Error`).\n *\n * @example\n * try {\n * await api.post(\"/users\", { body });\n * } catch (err) {\n * if (isApiError(err) && err.code === \"EMAIL_TAKEN\") {\n * showFieldError(\"email\", err.detail);\n * }\n * }\n */\nexport class TempestApiError extends Error implements ApiError {\n readonly status: number;\n readonly detail: string;\n readonly code?: string;\n readonly requestId?: string;\n readonly body?: unknown;\n\n constructor(init: ApiError) {\n super(init.detail);\n this.name = \"TempestApiError\";\n this.status = init.status;\n this.detail = init.detail;\n this.code = init.code;\n this.requestId = init.requestId;\n this.body = init.body;\n }\n}\n\n/**\n * Type guard for the {@link ApiError} shape. Matches both {@link TempestApiError}\n * instances and plain objects carrying `status` + `detail`.\n *\n * @param error - The unknown value (typically a caught error).\n * @returns Whether `error` conforms to the `ApiError` contract.\n */\nexport function isApiError(error: unknown): error is ApiError {\n return (\n typeof error === \"object\" &&\n error !== null &&\n typeof (error as ApiError).status === \"number\" &&\n typeof (error as ApiError).detail === \"string\"\n );\n}\n\n/**\n * Parse an error body + response into the Tempest {@link ApiError} envelope.\n *\n * Reads `detail`/`message`, the programmatic `code`, and the correlation id\n * from `details.request_id` (falling back to the `X-Request-ID` header, then\n * the id the client sent).\n *\n * @param status - HTTP status code.\n * @param body - The parsed error body (object, string, or null).\n * @param headers - The response headers (for the `X-Request-ID` fallback).\n * @param sentRequestId - The id the client sent on the request, if any.\n * @returns A fully-populated `ApiError`.\n *\n * @tempest-limits param-count — the arguments are the response as it arrives\n * (`status`, `body`, `headers`) plus the id the request was sent with, and they are\n * passed at exactly one place: the client's response path. Exported from the package\n * root, so the rewrite would be breaking for callers that build their own errors.\n */\nexport function buildApiError(\n status: number,\n body: unknown,\n headers?: Headers | { get(name: string): string | null },\n sentRequestId?: string,\n): ApiError {\n const obj =\n typeof body === \"object\" && body !== null ? (body as Record<string, unknown>) : null;\n const detail = obj?.detail ?? obj?.message ?? `Erro ${status}`;\n const code = typeof obj?.code === \"string\" ? obj.code : undefined;\n const details =\n typeof obj?.details === \"object\" && obj.details !== null\n ? (obj.details as Record<string, unknown>)\n : null;\n const requestId =\n (typeof details?.request_id === \"string\" ? details.request_id : undefined) ??\n headers?.get(\"X-Request-ID\") ??\n sentRequestId ??\n undefined;\n\n return {\n status,\n detail: String(detail),\n code,\n requestId: requestId ?? undefined,\n retryAfter: parseRetryAfter(headers?.get(\"Retry-After\")),\n body,\n };\n}\n\n/**\n * Parse a `Retry-After` header into seconds. Accepts a delta-seconds integer\n * (`\"120\"`) or an HTTP-date (`\"Wed, 21 Oct 2015 07:28:00 GMT\"`).\n *\n * @param value - The raw header value, or null.\n * @returns The delay in seconds (>= 0), or undefined when absent/unparseable.\n */\nexport function parseRetryAfter(value: string | null | undefined): number | undefined {\n if (!value) return undefined;\n const trimmed = value.trim();\n if (/^\\d+$/.test(trimmed)) return Number(trimmed);\n const when = Date.parse(trimmed);\n if (Number.isNaN(when)) return undefined;\n return Math.max(0, Math.round((when - Date.now()) / 1000));\n}\n"],"mappings":";AAkBA,IAAa,IAAb,cAAqC,MAA0B;CAC3D;CACA;CACA;CACA;CACA;CAEA,YAAY,GAAgB;EAOxB,AANA,MAAM,EAAK,MAAM,GACjB,KAAK,OAAO,mBACZ,KAAK,SAAS,EAAK,QACnB,KAAK,SAAS,EAAK,QACnB,KAAK,OAAO,EAAK,MACjB,KAAK,YAAY,EAAK,WACtB,KAAK,OAAO,EAAK;CACrB;AACJ;AASA,SAAgB,EAAW,GAAmC;CAC1D,OACI,OAAO,KAAU,cACjB,KACA,OAAQ,EAAmB,UAAW,YACtC,OAAQ,EAAmB,UAAW;AAE9C;AAoBA,SAAgB,EACZ,GACA,GACA,GACA,GACQ;CACR,IAAM,IACF,OAAO,KAAS,YAAY,IAAiB,IAAmC,MAC9E,IAAS,GAAK,UAAU,GAAK,WAAW,QAAQ,KAChD,IAAO,OAAO,GAAK,QAAS,WAAW,EAAI,OAAO,KAAA,GAClD,IACF,OAAO,GAAK,WAAY,YAAY,EAAI,YAAY,OAC7C,EAAI,UACL,MACJ,KACD,OAAO,GAAS,cAAe,WAAW,EAAQ,aAAa,KAAA,MAChE,GAAS,IAAI,cAAc,KAC3B,KACA,KAAA;CAEJ,OAAO;EACH;EACA,QAAQ,OAAO,CAAM;EACrB;EACA,WAAW,KAAa,KAAA;EACxB,YAAY,EAAgB,GAAS,IAAI,aAAa,CAAC;EACvD;CACJ;AACJ;AASA,SAAgB,EAAgB,GAAsD;CAClF,IAAI,CAAC,GAAO;CACZ,IAAM,IAAU,EAAM,KAAK;CAC3B,IAAI,QAAQ,KAAK,CAAO,GAAG,OAAO,OAAO,CAAO;CAChD,IAAM,IAAO,KAAK,MAAM,CAAO;CAC3B,YAAO,MAAM,CAAI,GACrB,OAAO,KAAK,IAAI,GAAG,KAAK,OAAO,IAAO,KAAK,IAAI,KAAK,GAAI,CAAC;AAC7D"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"resumable-upload.cjs","names":[],"sources":["../../src/http/resumable-upload.ts"],"sourcesContent":["import { buildApiError, TempestApiError } from \"./errors\";\nimport { generateIdempotencyKey } from \"./idempotency\";\nimport { retry, type RetryOptions } from \"./retry\";\n\n/** The tus protocol version this client speaks. */\nexport const TUS_VERSION = \"1.0.0\";\n\n/** Default chunk size: 5 MiB, the size most tus servers are tuned for. */\nexport const DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024;\n\n/**\n * Where a resumable upload is.\n *\n * `\"paused\"` and `\"aborted\"` are both \"not running\", but only `\"paused\"` keeps the\n * persisted offset — `abort({ discard: true })` throws it away.\n */\nexport type ResumableUploadState =\n \"idle\" | \"creating\" | \"uploading\" | \"paused\" | \"done\" | \"error\" | \"aborted\";\n\n/** Byte-level progress for a resumable upload. */\nexport interface ResumableUploadProgress {\n /** Bytes the server holds, including anything a resume skipped. */\n loaded: number;\n /** Total size of the file. */\n total: number;\n /** `loaded / total`, between 0 and 1. */\n fraction: number;\n /** Bytes already on the server when this run started. `0` on a fresh upload. */\n resumedFrom: number;\n}\n\n/** What has to survive a page reload for a resume to be possible. */\nexport interface ResumableUploadRecord {\n /** Upload URL the creation POST returned, absolute. */\n url: string;\n /** Last offset the server confirmed. */\n offset: number;\n /** File size, so a different file under the same key is not resumed into. */\n size: number;\n /** Idempotency key of the creation request, reused if creation is retried. */\n idempotencyKey: string;\n /** Epoch ms of the last write, so an app can sweep stale records. */\n updatedAt: number;\n}\n\n/**\n * Persistence for resume state. Sync or async — both are awaited.\n *\n * Implement it over anything: the default is `localStorage`, and\n * `createOfflineStore` from `@/offline` slots in when you already have a Dexie\n * database open.\n */\nexport interface ResumableUploadStorage {\n /** Read the record for `key`, or `null`. */\n get(key: string): Promise<ResumableUploadRecord | null> | ResumableUploadRecord | null;\n /** Write the record for `key`. */\n set(key: string, record: ResumableUploadRecord): Promise<void> | void;\n /** Forget the record for `key`. */\n delete(key: string): Promise<void> | void;\n}\n\n/** Options for {@link createResumableUpload}. */\nexport interface ResumableUploadOptions {\n /** tus creation endpoint, e.g. `\"/api/uploads\"`. */\n endpoint: string;\n /** The bytes to upload. A `File` also supplies the default resume key. */\n file: Blob | File;\n /** Bytes per `PATCH`. Default {@link DEFAULT_CHUNK_SIZE}. */\n chunkSize?: number;\n /** Sent as `Upload-Metadata` (base64-encoded values), e.g. `{ filename }`. */\n metadata?: Record<string, string>;\n /** Extra headers on every request. */\n headers?: Record<string, string>;\n /** Returns the current bearer token, read before each request. */\n getToken?: () => string | null | undefined;\n /** Send cookies. Default `false`. */\n withCredentials?: boolean;\n /**\n * Resume key. Defaults to a fingerprint of endpoint + file name/size/mtime, so\n * picking the same file after a reload resumes instead of restarting.\n */\n key?: string;\n /**\n * Where to persist resume state. Defaults to `localStorage`. Pass `null` to\n * disable persistence — resume then only survives a network blip, not a reload.\n */\n storage?: ResumableUploadStorage | null;\n /** Backoff for a failed chunk. Forwarded to `retry`. Default 5 attempts. */\n retry?: RetryOptions;\n /** Called on every upload-progress tick and after every confirmed chunk. */\n onProgress?: (progress: ResumableUploadProgress) => void;\n /** Called whenever {@link ResumableUpload.state} changes. */\n onStateChange?: (state: ResumableUploadState) => void;\n}\n\n/** What a finished upload resolves with. */\nexport interface ResumableUploadResult {\n /** The tus upload URL — hand this to your API to link the stored file. */\n url: string;\n /** Total bytes uploaded. */\n size: number;\n}\n\n/** A resumable upload in progress. Build one with {@link createResumableUpload}. */\nexport interface ResumableUpload {\n /**\n * Create (or re-attach to) the upload and push chunks until it is complete.\n *\n * Resolves `null` when the run stopped because of `pause()` or `abort()` —\n * neither is a failure. Rejects with a `TempestApiError` when the server\n * refused and the retries ran out.\n */\n start(): Promise<ResumableUploadResult | null>;\n /** Stop after the in-flight chunk is dropped, keeping the resume point. */\n pause(): void;\n /** Continue from the server's offset. Same resolution contract as `start`. */\n resume(): Promise<ResumableUploadResult | null>;\n /**\n * Stop for good.\n *\n * @param options - `discard: true` also sends `DELETE` (tus termination) and\n * forgets the persisted record, so the next `start()` uploads from zero.\n */\n abort(options?: { discard?: boolean }): Promise<void>;\n /** Current state. */\n readonly state: ResumableUploadState;\n /** Bytes the server has confirmed. */\n readonly offset: number;\n /** The upload URL, once creation succeeded. */\n readonly url: string | null;\n /** The resume key in use. */\n readonly key: string;\n}\n\ninterface RawResponse {\n status: number;\n text: string;\n header(name: string): string | null;\n}\n\n/**\n * Encode a string as standard base64 (padded), UTF-8 first.\n *\n * `Upload-Metadata` carries base64 values precisely so a filename with accents\n * survives an HTTP header, so the UTF-8 step is not optional: `btoa` alone throws\n * on any code point above U+00FF.\n *\n * @param value - Text to encode.\n * @returns Padded base64.\n */\nfunction base64Utf8(value: string): string {\n const bytes = new TextEncoder().encode(value);\n let binary = \"\";\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return btoa(binary);\n}\n\n/**\n * Build the `Upload-Metadata` header value: comma-separated `key base64(value)`.\n *\n * @param metadata - Plain string map.\n * @returns The header value, or `null` when there is nothing to send.\n */\nfunction encodeMetadata(metadata: Record<string, string> | undefined): string | null {\n if (!metadata) return null;\n const parts = Object.entries(metadata).map(([name, value]) => `${name} ${base64Utf8(value)}`);\n return parts.length > 0 ? parts.join(\",\") : null;\n}\n\n/**\n * A stable-enough identity for a file, used as the default resume key.\n *\n * Name + size + last-modified is what the tus reference clients fingerprint on:\n * it is cheap (hashing the bytes of a 400 MB recording is not) and it changes\n * whenever the file does, which is the property that matters — resuming into the\n * wrong file would corrupt it silently.\n *\n * @param endpoint - Creation endpoint, so the same file to two servers is two uploads.\n * @param file - The blob or file being uploaded.\n * @returns A key safe to use in `localStorage`.\n */\nexport function uploadFingerprint(endpoint: string, file: Blob | File): string {\n const named = file as File;\n const name = typeof named.name === \"string\" ? named.name : \"blob\";\n const modified = typeof named.lastModified === \"number\" ? named.lastModified : 0;\n return `${endpoint}|${name}|${file.size}|${file.type}|${modified}`;\n}\n\n/**\n * `localStorage`-backed resume storage — the default.\n *\n * `localStorage` and not IndexedDB on purpose. The record is four fields and a\n * URL; the requirement is only that it survives a reload, and pulling Dexie in for\n * that would put an IndexedDB dependency in the bundle of every app that uploads a\n * file. Apps that already have `createOfflineStore` open can pass their own\n * {@link ResumableUploadStorage} instead.\n *\n * @param prefix - Key prefix. Default `\"tempest-upload:\"`.\n * @returns A storage that no-ops when `localStorage` is unavailable.\n */\nexport function createLocalUploadStorage(prefix = \"tempest-upload:\"): ResumableUploadStorage {\n function backend(): Storage | null {\n try {\n return typeof localStorage === \"undefined\" ? null : localStorage;\n } catch {\n return null;\n }\n }\n\n return {\n get(key) {\n const raw = backend()?.getItem(prefix + key);\n if (!raw) return null;\n try {\n return JSON.parse(raw) as ResumableUploadRecord;\n } catch {\n return null;\n }\n },\n set(key, record) {\n backend()?.setItem(prefix + key, JSON.stringify(record));\n },\n delete(key) {\n backend()?.removeItem(prefix + key);\n },\n };\n}\n\n/**\n * Send one request over `XMLHttpRequest`.\n *\n * `XMLHttpRequest` rather than `fetch` for the same reason `uploadWithProgress`\n * uses it — `fetch` still cannot report upload progress in any browser — plus one\n * more: tus answers every write with the new `Upload-Offset` in a **response\n * header**, and `uploadWithProgress` only hands back a parsed body, so it could\n * not be reused here.\n *\n * @param init - Method, URL, headers, optional body and progress callback.\n * @returns Status, raw text and a header reader.\n */\nfunction sendRequest(init: {\n method: \"POST\" | \"HEAD\" | \"PATCH\" | \"DELETE\";\n url: string;\n headers: Record<string, string>;\n body?: Blob;\n withCredentials: boolean;\n onProgress?: (loaded: number) => void;\n register: (xhr: XMLHttpRequest) => void;\n}): Promise<RawResponse> {\n return new Promise<RawResponse>((resolve, reject) => {\n const xhr = new XMLHttpRequest();\n xhr.open(init.method, init.url);\n xhr.withCredentials = init.withCredentials;\n for (const [name, value] of Object.entries(init.headers)) {\n xhr.setRequestHeader(name, value);\n }\n if (init.onProgress) {\n const report = init.onProgress;\n xhr.upload.onprogress = (event: ProgressEvent) => report(event.loaded);\n }\n xhr.onload = () =>\n resolve({\n status: xhr.status,\n text: xhr.responseText,\n header: (name) => xhr.getResponseHeader(name),\n });\n xhr.onerror = () =>\n reject(\n new TempestApiError({\n status: 0,\n detail: \"Falha de rede no upload resumível.\",\n }),\n );\n xhr.onabort = () => reject(new DOMException(\"Aborted\", \"AbortError\"));\n init.register(xhr);\n xhr.send(init.body);\n });\n}\n\nfunction parseOffset(response: RawResponse): number | null {\n const raw = response.header(\"Upload-Offset\");\n if (raw === null) return null;\n const value = Number(raw);\n return Number.isFinite(value) && value >= 0 ? value : null;\n}\n\n/**\n * Read an error body without assuming it is JSON.\n *\n * A tus proxy that rejects a chunk often answers with plain text or an HTML error\n * page, and `JSON.parse` throwing there would replace a useful status with a parse\n * error.\n *\n * @param text - Raw response text.\n * @returns The parsed object, the raw text, or `null` when the body was empty.\n */\nfunction parseErrorBody(text: string): unknown {\n if (!text) return null;\n try {\n return JSON.parse(text);\n } catch {\n return text;\n }\n}\n\n/**\n * Turn a refused tus response into a `TempestApiError`.\n *\n * The fallback `detail` is used unless the server sent a real error envelope,\n * because `buildApiError`'s own fallback (`\"Erro 409\"`) says nothing about which\n * step of the protocol broke — and that is the whole diagnostic value here.\n *\n * @param response - The raw response that was not acceptable.\n * @param detail - Message to use when the body carries none.\n * @returns The error to throw.\n */\nfunction failed(response: RawResponse, detail: string): TempestApiError {\n const body = parseErrorBody(response.text);\n const envelope = buildApiError(response.status, body, { get: response.header });\n const hasDetail =\n typeof body === \"object\" && body !== null && (\"detail\" in body || \"message\" in body);\n return new TempestApiError({ ...envelope, detail: hasDetail ? envelope.detail : detail });\n}\n\n/**\n * Resolve a `Location` header against the page, so a relative upload URL works.\n *\n * tus servers are free to answer creation with either an absolute URL or a\n * path, and the spec does not prefer one — a client that only handles absolute\n * URLs breaks against half the implementations.\n *\n * @param value - The raw `Location` header.\n * @returns An absolute URL, or the input when there is no base to resolve against.\n */\nfunction resolveUploadUrl(value: string): string {\n const base = typeof window === \"undefined\" ? undefined : window.location.href;\n try {\n return new URL(value, base).href;\n } catch {\n return value;\n }\n}\n\n/**\n * Chunked, resumable upload speaking the **tus 1.0.0** protocol (core plus the\n * *creation* and *termination* extensions).\n *\n * ## Why tus and not a bespoke scheme\n *\n * A resumable client whose wire format is undocumented cannot be integrated, and\n * inventing one means the backend is ours forever. tus is a published spec with\n * off-the-shelf servers (`tusd`, `tuspy`, `tus-node-server`), so a caller can point\n * this at something they did not write.\n *\n * ## What the backend must implement\n *\n * Every request carries `Tus-Resumable: 1.0.0`.\n *\n * | Step | Request | Expected response |\n * | --- | --- | --- |\n * | Create | `POST {endpoint}` + `Upload-Length`, `Upload-Metadata`, `Idempotency-Key` | `201` + `Location` (the upload URL, absolute or endpoint-relative) |\n * | Probe | `HEAD {uploadUrl}` | `200`/`204` + `Upload-Offset` |\n * | Write | `PATCH {uploadUrl}` + `Upload-Offset`, `Content-Type: application/offset+octet-stream`, chunk body | `204` + the new `Upload-Offset`; `409` when the offset does not match |\n * | Discard | `DELETE {uploadUrl}` | `204` |\n *\n * ## The failure that actually happens\n *\n * A chunk that the server stored but whose response never arrived. The client\n * cannot tell that from a chunk that was lost, and re-sending it blindly would\n * duplicate bytes. Two things prevent that:\n *\n * - **Writes are addressed, not appended.** Every `PATCH` states the offset it\n * writes at, so a retry after a lost response is asked to write bytes the server\n * already has and answers `409`. On any retry the client re-reads the truth with\n * `HEAD` first and continues from there.\n * - **Creation carries an `Idempotency-Key`** (from `generateIdempotencyKey`),\n * persisted before the first attempt and reused on retry. tus has no idempotent\n * creation of its own, so without this a lost `201` leaves an orphan upload on\n * the server. A backend that honours the header returns the same `Location`; one\n * that ignores it still works, it just keeps the orphan.\n *\n * @param options - Endpoint, file, and the knobs above.\n * @returns A handle with `start`/`pause`/`resume`/`abort` and live `state`/`offset`.\n *\n * @example\n * const upload = createResumableUpload({\n * endpoint: \"/api/uploads\",\n * file: recording,\n * metadata: { filename: \"nota.webm\", ticket: ticketId },\n * getToken: () => auth.getToken(),\n * onProgress: ({ fraction }) => setPercent(Math.round(fraction * 100)),\n * });\n *\n * const done = await upload.start();\n * if (done) await api.post(\"/api/tickets/1/audio\", { body: { url: done.url } });\n */\nexport function createResumableUpload(options: ResumableUploadOptions): ResumableUpload {\n const {\n endpoint,\n file,\n chunkSize = DEFAULT_CHUNK_SIZE,\n metadata,\n headers = {},\n getToken,\n withCredentials = false,\n key = uploadFingerprint(endpoint, file),\n storage = createLocalUploadStorage(),\n retry: retryOptions,\n onProgress,\n onStateChange,\n } = options;\n\n let state: ResumableUploadState = \"idle\";\n let offset = 0;\n let url: string | null = null;\n let idempotencyKey: string | null = null;\n let stopping: \"pause\" | \"abort\" | null = null;\n let inFlight: XMLHttpRequest | null = null;\n let resumedFrom = 0;\n\n function setState(next: ResumableUploadState): void {\n if (state === next) return;\n state = next;\n onStateChange?.(next);\n }\n\n function report(loaded: number): void {\n onProgress?.({\n loaded,\n total: file.size,\n fraction: file.size === 0 ? 1 : loaded / file.size,\n resumedFrom,\n });\n }\n\n function baseHeaders(): Record<string, string> {\n const result: Record<string, string> = { ...headers, \"Tus-Resumable\": TUS_VERSION };\n const token = getToken?.();\n if (token && !(\"Authorization\" in result)) result.Authorization = `Bearer ${token}`;\n return result;\n }\n\n function register(xhr: XMLHttpRequest): void {\n inFlight = xhr;\n }\n\n async function persist(): Promise<void> {\n if (!storage || !url || !idempotencyKey) return;\n await storage.set(key, {\n url,\n offset,\n size: file.size,\n idempotencyKey,\n updatedAt: Date.now(),\n });\n }\n\n /**\n * Ask the server how much it holds. The only source of truth after any failure.\n */\n async function probe(target: string): Promise<number> {\n const response = await sendRequest({\n method: \"HEAD\",\n url: target,\n headers: baseHeaders(),\n withCredentials,\n register,\n });\n if (response.status === 404 || response.status === 410) {\n throw new TempestApiError({\n status: response.status,\n detail: \"O upload expirou no servidor. Comece de novo.\",\n });\n }\n const confirmed = parseOffset(response);\n if (confirmed === null) throw failed(response, \"HEAD sem Upload-Offset.\");\n return confirmed;\n }\n\n /**\n * Re-attach to a persisted upload, or create a new one.\n *\n * The persisted record is only trusted when the file size still matches, and the\n * offset it holds is re-checked with `HEAD` — the client's copy can be ahead of\n * the server's whenever the last response was lost.\n */\n async function ensureUpload(): Promise<string> {\n const stored = storage ? await storage.get(key) : null;\n if (stored && stored.size === file.size) {\n idempotencyKey = stored.idempotencyKey;\n if (stored.url) {\n try {\n offset = await probe(stored.url);\n url = stored.url;\n return stored.url;\n } catch {\n offset = 0;\n }\n }\n }\n\n setState(\"creating\");\n idempotencyKey ??= generateIdempotencyKey();\n url = null;\n offset = 0;\n if (storage) {\n await storage.set(key, {\n url: \"\",\n offset: 0,\n size: file.size,\n idempotencyKey,\n updatedAt: Date.now(),\n });\n }\n\n const creationHeaders: Record<string, string> = {\n ...baseHeaders(),\n \"Upload-Length\": String(file.size),\n \"Idempotency-Key\": idempotencyKey,\n };\n const encoded = encodeMetadata(metadata);\n if (encoded) creationHeaders[\"Upload-Metadata\"] = encoded;\n\n const response = await sendRequest({\n method: \"POST\",\n url: endpoint,\n headers: creationHeaders,\n withCredentials,\n register,\n });\n if (response.status !== 201) throw failed(response, \"Criação do upload recusada.\");\n const locationHeader = response.header(\"Location\");\n if (!locationHeader) throw failed(response, \"Criação do upload sem cabeçalho Location.\");\n\n url = resolveUploadUrl(locationHeader);\n await persist();\n return url;\n }\n\n /** Push one chunk, resyncing the offset first when a previous attempt failed. */\n async function writeChunk(target: string, resync: { needed: boolean }): Promise<void> {\n if (resync.needed) {\n offset = await probe(target);\n resync.needed = false;\n report(offset);\n await persist();\n if (offset >= file.size) return;\n }\n\n const end = Math.min(offset + chunkSize, file.size);\n const from = offset;\n const response = await sendRequest({\n method: \"PATCH\",\n url: target,\n headers: {\n ...baseHeaders(),\n \"Content-Type\": \"application/offset+octet-stream\",\n \"Upload-Offset\": String(from),\n },\n body: file.slice(from, end),\n withCredentials,\n onProgress: (loaded) => report(Math.min(from + loaded, file.size)),\n register,\n });\n\n if (response.status === 409 || response.status === 412) {\n resync.needed = true;\n throw failed(response, \"Offset divergente — o servidor já tinha esses bytes.\");\n }\n if (response.status !== 204 && response.status !== 200) {\n throw failed(response, \"Chunk recusado pelo servidor.\");\n }\n\n offset = parseOffset(response) ?? end;\n report(offset);\n await persist();\n }\n\n /**\n * Drive the whole upload: attach or create, then chunk until complete.\n *\n * The `shouldRetry` predicate does double duty — besides deciding, it arms\n * `resync` so the next attempt re-reads the server's offset with `HEAD` before\n * writing. That is deliberate: it is the one place that sees *every* chunk\n * failure, whatever the cause, and after any failure the client's idea of the\n * offset is exactly what cannot be trusted.\n *\n * @returns The result, or `null` when `pause`/`abort` stopped the run.\n */\n async function run(): Promise<ResumableUploadResult | null> {\n stopping = null;\n const target = await ensureUpload();\n resumedFrom = offset;\n setState(\"uploading\");\n report(offset);\n\n const resync = { needed: false };\n while (offset < file.size) {\n if (stopping) break;\n await retry(() => writeChunk(target, resync), {\n retries: 5,\n ...retryOptions,\n shouldRetry: (error, attempt) => {\n if (stopping) return false;\n if (error instanceof DOMException && error.name === \"AbortError\") return false;\n resync.needed = true;\n return retryOptions?.shouldRetry?.(error, attempt) ?? true;\n },\n });\n }\n\n if (stopping === \"pause\") {\n setState(\"paused\");\n return null;\n }\n if (stopping === \"abort\") {\n setState(\"aborted\");\n return null;\n }\n\n setState(\"done\");\n if (storage) await storage.delete(key);\n return { url: target, size: file.size };\n }\n\n async function guarded(): Promise<ResumableUploadResult | null> {\n try {\n return await run();\n } catch (error) {\n if (\n stopping !== null ||\n (error instanceof DOMException && error.name === \"AbortError\")\n ) {\n setState(stopping === \"abort\" ? \"aborted\" : \"paused\");\n return null;\n }\n setState(\"error\");\n throw error;\n } finally {\n inFlight = null;\n }\n }\n\n function stop(reason: \"pause\" | \"abort\"): void {\n stopping = reason;\n inFlight?.abort();\n inFlight = null;\n }\n\n return {\n start: guarded,\n resume: guarded,\n pause: () => stop(\"pause\"),\n abort: async ({ discard = false } = {}) => {\n stop(\"abort\");\n setState(\"aborted\");\n if (!discard) return;\n if (url) {\n await sendRequest({\n method: \"DELETE\",\n url,\n headers: baseHeaders(),\n withCredentials,\n register: () => undefined,\n }).catch(() => undefined);\n }\n if (storage) await storage.delete(key);\n },\n get state() {\n return state;\n },\n get offset() {\n return offset;\n },\n get url() {\n return url;\n },\n key,\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"resumable-upload.cjs","names":[],"sources":["../../src/http/resumable-upload.ts"],"sourcesContent":["/**\n * @tempest-limits file-lines, function-lines — a resumable upload is one long-lived\n * state machine: chunk the file, negotiate the offset the server already has, upload\n * with retry and backoff, honour pause, resume and abort, and report progress\n * throughout. Every stage reads the same cursor and the same abort signal, and\n * createResumableUpload is the closure that owns them.\n */\nimport { buildApiError, TempestApiError } from \"./errors\";\nimport { generateIdempotencyKey } from \"./idempotency\";\nimport { retry, type RetryOptions } from \"./retry\";\n\n/** The tus protocol version this client speaks. */\nexport const TUS_VERSION = \"1.0.0\";\n\n/** Default chunk size: 5 MiB, the size most tus servers are tuned for. */\nexport const DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024;\n\n/**\n * Where a resumable upload is.\n *\n * `\"paused\"` and `\"aborted\"` are both \"not running\", but only `\"paused\"` keeps the\n * persisted offset — `abort({ discard: true })` throws it away.\n */\nexport type ResumableUploadState =\n \"idle\" | \"creating\" | \"uploading\" | \"paused\" | \"done\" | \"error\" | \"aborted\";\n\n/** Byte-level progress for a resumable upload. */\nexport interface ResumableUploadProgress {\n /** Bytes the server holds, including anything a resume skipped. */\n loaded: number;\n /** Total size of the file. */\n total: number;\n /** `loaded / total`, between 0 and 1. */\n fraction: number;\n /** Bytes already on the server when this run started. `0` on a fresh upload. */\n resumedFrom: number;\n}\n\n/** What has to survive a page reload for a resume to be possible. */\nexport interface ResumableUploadRecord {\n /** Upload URL the creation POST returned, absolute. */\n url: string;\n /** Last offset the server confirmed. */\n offset: number;\n /** File size, so a different file under the same key is not resumed into. */\n size: number;\n /** Idempotency key of the creation request, reused if creation is retried. */\n idempotencyKey: string;\n /** Epoch ms of the last write, so an app can sweep stale records. */\n updatedAt: number;\n}\n\n/**\n * Persistence for resume state. Sync or async — both are awaited.\n *\n * Implement it over anything: the default is `localStorage`, and\n * `createOfflineStore` from `@/offline` slots in when you already have a Dexie\n * database open.\n */\nexport interface ResumableUploadStorage {\n /** Read the record for `key`, or `null`. */\n get(key: string): Promise<ResumableUploadRecord | null> | ResumableUploadRecord | null;\n /** Write the record for `key`. */\n set(key: string, record: ResumableUploadRecord): Promise<void> | void;\n /** Forget the record for `key`. */\n delete(key: string): Promise<void> | void;\n}\n\n/** Options for {@link createResumableUpload}. */\nexport interface ResumableUploadOptions {\n /** tus creation endpoint, e.g. `\"/api/uploads\"`. */\n endpoint: string;\n /** The bytes to upload. A `File` also supplies the default resume key. */\n file: Blob | File;\n /** Bytes per `PATCH`. Default {@link DEFAULT_CHUNK_SIZE}. */\n chunkSize?: number;\n /** Sent as `Upload-Metadata` (base64-encoded values), e.g. `{ filename }`. */\n metadata?: Record<string, string>;\n /** Extra headers on every request. */\n headers?: Record<string, string>;\n /** Returns the current bearer token, read before each request. */\n getToken?: () => string | null | undefined;\n /** Send cookies. Default `false`. */\n withCredentials?: boolean;\n /**\n * Resume key. Defaults to a fingerprint of endpoint + file name/size/mtime, so\n * picking the same file after a reload resumes instead of restarting.\n */\n key?: string;\n /**\n * Where to persist resume state. Defaults to `localStorage`. Pass `null` to\n * disable persistence — resume then only survives a network blip, not a reload.\n */\n storage?: ResumableUploadStorage | null;\n /** Backoff for a failed chunk. Forwarded to `retry`. Default 5 attempts. */\n retry?: RetryOptions;\n /** Called on every upload-progress tick and after every confirmed chunk. */\n onProgress?: (progress: ResumableUploadProgress) => void;\n /** Called whenever {@link ResumableUpload.state} changes. */\n onStateChange?: (state: ResumableUploadState) => void;\n}\n\n/** What a finished upload resolves with. */\nexport interface ResumableUploadResult {\n /** The tus upload URL — hand this to your API to link the stored file. */\n url: string;\n /** Total bytes uploaded. */\n size: number;\n}\n\n/** A resumable upload in progress. Build one with {@link createResumableUpload}. */\nexport interface ResumableUpload {\n /**\n * Create (or re-attach to) the upload and push chunks until it is complete.\n *\n * Resolves `null` when the run stopped because of `pause()` or `abort()` —\n * neither is a failure. Rejects with a `TempestApiError` when the server\n * refused and the retries ran out.\n */\n start(): Promise<ResumableUploadResult | null>;\n /** Stop after the in-flight chunk is dropped, keeping the resume point. */\n pause(): void;\n /** Continue from the server's offset. Same resolution contract as `start`. */\n resume(): Promise<ResumableUploadResult | null>;\n /**\n * Stop for good.\n *\n * @param options - `discard: true` also sends `DELETE` (tus termination) and\n * forgets the persisted record, so the next `start()` uploads from zero.\n */\n abort(options?: { discard?: boolean }): Promise<void>;\n /** Current state. */\n readonly state: ResumableUploadState;\n /** Bytes the server has confirmed. */\n readonly offset: number;\n /** The upload URL, once creation succeeded. */\n readonly url: string | null;\n /** The resume key in use. */\n readonly key: string;\n}\n\ninterface RawResponse {\n status: number;\n text: string;\n header(name: string): string | null;\n}\n\n/**\n * Encode a string as standard base64 (padded), UTF-8 first.\n *\n * `Upload-Metadata` carries base64 values precisely so a filename with accents\n * survives an HTTP header, so the UTF-8 step is not optional: `btoa` alone throws\n * on any code point above U+00FF.\n *\n * @param value - Text to encode.\n * @returns Padded base64.\n */\nfunction base64Utf8(value: string): string {\n const bytes = new TextEncoder().encode(value);\n let binary = \"\";\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return btoa(binary);\n}\n\n/**\n * Build the `Upload-Metadata` header value: comma-separated `key base64(value)`.\n *\n * @param metadata - Plain string map.\n * @returns The header value, or `null` when there is nothing to send.\n */\nfunction encodeMetadata(metadata: Record<string, string> | undefined): string | null {\n if (!metadata) return null;\n const parts = Object.entries(metadata).map(([name, value]) => `${name} ${base64Utf8(value)}`);\n return parts.length > 0 ? parts.join(\",\") : null;\n}\n\n/**\n * A stable-enough identity for a file, used as the default resume key.\n *\n * Name + size + last-modified is what the tus reference clients fingerprint on:\n * it is cheap (hashing the bytes of a 400 MB recording is not) and it changes\n * whenever the file does, which is the property that matters — resuming into the\n * wrong file would corrupt it silently.\n *\n * @param endpoint - Creation endpoint, so the same file to two servers is two uploads.\n * @param file - The blob or file being uploaded.\n * @returns A key safe to use in `localStorage`.\n */\nexport function uploadFingerprint(endpoint: string, file: Blob | File): string {\n const named = file as File;\n const name = typeof named.name === \"string\" ? named.name : \"blob\";\n const modified = typeof named.lastModified === \"number\" ? named.lastModified : 0;\n return `${endpoint}|${name}|${file.size}|${file.type}|${modified}`;\n}\n\n/**\n * `localStorage`-backed resume storage — the default.\n *\n * `localStorage` and not IndexedDB on purpose. The record is four fields and a\n * URL; the requirement is only that it survives a reload, and pulling Dexie in for\n * that would put an IndexedDB dependency in the bundle of every app that uploads a\n * file. Apps that already have `createOfflineStore` open can pass their own\n * {@link ResumableUploadStorage} instead.\n *\n * @param prefix - Key prefix. Default `\"tempest-upload:\"`.\n * @returns A storage that no-ops when `localStorage` is unavailable.\n */\nexport function createLocalUploadStorage(prefix = \"tempest-upload:\"): ResumableUploadStorage {\n function backend(): Storage | null {\n try {\n return typeof localStorage === \"undefined\" ? null : localStorage;\n } catch {\n return null;\n }\n }\n\n return {\n get(key) {\n const raw = backend()?.getItem(prefix + key);\n if (!raw) return null;\n try {\n return JSON.parse(raw) as ResumableUploadRecord;\n } catch {\n return null;\n }\n },\n set(key, record) {\n backend()?.setItem(prefix + key, JSON.stringify(record));\n },\n delete(key) {\n backend()?.removeItem(prefix + key);\n },\n };\n}\n\n/**\n * Send one request over `XMLHttpRequest`.\n *\n * `XMLHttpRequest` rather than `fetch` for the same reason `uploadWithProgress`\n * uses it — `fetch` still cannot report upload progress in any browser — plus one\n * more: tus answers every write with the new `Upload-Offset` in a **response\n * header**, and `uploadWithProgress` only hands back a parsed body, so it could\n * not be reused here.\n *\n * @param init - Method, URL, headers, optional body and progress callback.\n * @returns Status, raw text and a header reader.\n */\nfunction sendRequest(init: {\n method: \"POST\" | \"HEAD\" | \"PATCH\" | \"DELETE\";\n url: string;\n headers: Record<string, string>;\n body?: Blob;\n withCredentials: boolean;\n onProgress?: (loaded: number) => void;\n register: (xhr: XMLHttpRequest) => void;\n}): Promise<RawResponse> {\n return new Promise<RawResponse>((resolve, reject) => {\n const xhr = new XMLHttpRequest();\n xhr.open(init.method, init.url);\n xhr.withCredentials = init.withCredentials;\n for (const [name, value] of Object.entries(init.headers)) {\n xhr.setRequestHeader(name, value);\n }\n if (init.onProgress) {\n const report = init.onProgress;\n xhr.upload.onprogress = (event: ProgressEvent) => report(event.loaded);\n }\n xhr.onload = () =>\n resolve({\n status: xhr.status,\n text: xhr.responseText,\n header: (name) => xhr.getResponseHeader(name),\n });\n xhr.onerror = () =>\n reject(\n new TempestApiError({\n status: 0,\n detail: \"Falha de rede no upload resumível.\",\n }),\n );\n xhr.onabort = () => reject(new DOMException(\"Aborted\", \"AbortError\"));\n init.register(xhr);\n xhr.send(init.body);\n });\n}\n\nfunction parseOffset(response: RawResponse): number | null {\n const raw = response.header(\"Upload-Offset\");\n if (raw === null) return null;\n const value = Number(raw);\n return Number.isFinite(value) && value >= 0 ? value : null;\n}\n\n/**\n * Read an error body without assuming it is JSON.\n *\n * A tus proxy that rejects a chunk often answers with plain text or an HTML error\n * page, and `JSON.parse` throwing there would replace a useful status with a parse\n * error.\n *\n * @param text - Raw response text.\n * @returns The parsed object, the raw text, or `null` when the body was empty.\n */\nfunction parseErrorBody(text: string): unknown {\n if (!text) return null;\n try {\n return JSON.parse(text);\n } catch {\n return text;\n }\n}\n\n/**\n * Turn a refused tus response into a `TempestApiError`.\n *\n * The fallback `detail` is used unless the server sent a real error envelope,\n * because `buildApiError`'s own fallback (`\"Erro 409\"`) says nothing about which\n * step of the protocol broke — and that is the whole diagnostic value here.\n *\n * @param response - The raw response that was not acceptable.\n * @param detail - Message to use when the body carries none.\n * @returns The error to throw.\n */\nfunction failed(response: RawResponse, detail: string): TempestApiError {\n const body = parseErrorBody(response.text);\n const envelope = buildApiError(response.status, body, { get: response.header });\n const hasDetail =\n typeof body === \"object\" && body !== null && (\"detail\" in body || \"message\" in body);\n return new TempestApiError({ ...envelope, detail: hasDetail ? envelope.detail : detail });\n}\n\n/**\n * Resolve a `Location` header against the page, so a relative upload URL works.\n *\n * tus servers are free to answer creation with either an absolute URL or a\n * path, and the spec does not prefer one — a client that only handles absolute\n * URLs breaks against half the implementations.\n *\n * @param value - The raw `Location` header.\n * @returns An absolute URL, or the input when there is no base to resolve against.\n */\nfunction resolveUploadUrl(value: string): string {\n const base = typeof window === \"undefined\" ? undefined : window.location.href;\n try {\n return new URL(value, base).href;\n } catch {\n return value;\n }\n}\n\n/**\n * Chunked, resumable upload speaking the **tus 1.0.0** protocol (core plus the\n * *creation* and *termination* extensions).\n *\n * ## Why tus and not a bespoke scheme\n *\n * A resumable client whose wire format is undocumented cannot be integrated, and\n * inventing one means the backend is ours forever. tus is a published spec with\n * off-the-shelf servers (`tusd`, `tuspy`, `tus-node-server`), so a caller can point\n * this at something they did not write.\n *\n * ## What the backend must implement\n *\n * Every request carries `Tus-Resumable: 1.0.0`.\n *\n * | Step | Request | Expected response |\n * | --- | --- | --- |\n * | Create | `POST {endpoint}` + `Upload-Length`, `Upload-Metadata`, `Idempotency-Key` | `201` + `Location` (the upload URL, absolute or endpoint-relative) |\n * | Probe | `HEAD {uploadUrl}` | `200`/`204` + `Upload-Offset` |\n * | Write | `PATCH {uploadUrl}` + `Upload-Offset`, `Content-Type: application/offset+octet-stream`, chunk body | `204` + the new `Upload-Offset`; `409` when the offset does not match |\n * | Discard | `DELETE {uploadUrl}` | `204` |\n *\n * ## The failure that actually happens\n *\n * A chunk that the server stored but whose response never arrived. The client\n * cannot tell that from a chunk that was lost, and re-sending it blindly would\n * duplicate bytes. Two things prevent that:\n *\n * - **Writes are addressed, not appended.** Every `PATCH` states the offset it\n * writes at, so a retry after a lost response is asked to write bytes the server\n * already has and answers `409`. On any retry the client re-reads the truth with\n * `HEAD` first and continues from there.\n * - **Creation carries an `Idempotency-Key`** (from `generateIdempotencyKey`),\n * persisted before the first attempt and reused on retry. tus has no idempotent\n * creation of its own, so without this a lost `201` leaves an orphan upload on\n * the server. A backend that honours the header returns the same `Location`; one\n * that ignores it still works, it just keeps the orphan.\n *\n * @param options - Endpoint, file, and the knobs above.\n * @returns A handle with `start`/`pause`/`resume`/`abort` and live `state`/`offset`.\n *\n * @example\n * const upload = createResumableUpload({\n * endpoint: \"/api/uploads\",\n * file: recording,\n * metadata: { filename: \"nota.webm\", ticket: ticketId },\n * getToken: () => auth.getToken(),\n * onProgress: ({ fraction }) => setPercent(Math.round(fraction * 100)),\n * });\n *\n * const done = await upload.start();\n * if (done) await api.post(\"/api/tickets/1/audio\", { body: { url: done.url } });\n */\nexport function createResumableUpload(options: ResumableUploadOptions): ResumableUpload {\n const {\n endpoint,\n file,\n chunkSize = DEFAULT_CHUNK_SIZE,\n metadata,\n headers = {},\n getToken,\n withCredentials = false,\n key = uploadFingerprint(endpoint, file),\n storage = createLocalUploadStorage(),\n retry: retryOptions,\n onProgress,\n onStateChange,\n } = options;\n\n let state: ResumableUploadState = \"idle\";\n let offset = 0;\n let url: string | null = null;\n let idempotencyKey: string | null = null;\n let stopping: \"pause\" | \"abort\" | null = null;\n let inFlight: XMLHttpRequest | null = null;\n let resumedFrom = 0;\n\n function setState(next: ResumableUploadState): void {\n if (state === next) return;\n state = next;\n onStateChange?.(next);\n }\n\n function report(loaded: number): void {\n onProgress?.({\n loaded,\n total: file.size,\n fraction: file.size === 0 ? 1 : loaded / file.size,\n resumedFrom,\n });\n }\n\n function baseHeaders(): Record<string, string> {\n const result: Record<string, string> = { ...headers, \"Tus-Resumable\": TUS_VERSION };\n const token = getToken?.();\n if (token && !(\"Authorization\" in result)) result.Authorization = `Bearer ${token}`;\n return result;\n }\n\n function register(xhr: XMLHttpRequest): void {\n inFlight = xhr;\n }\n\n async function persist(): Promise<void> {\n if (!storage || !url || !idempotencyKey) return;\n await storage.set(key, {\n url,\n offset,\n size: file.size,\n idempotencyKey,\n updatedAt: Date.now(),\n });\n }\n\n /**\n * Ask the server how much it holds. The only source of truth after any failure.\n */\n async function probe(target: string): Promise<number> {\n const response = await sendRequest({\n method: \"HEAD\",\n url: target,\n headers: baseHeaders(),\n withCredentials,\n register,\n });\n if (response.status === 404 || response.status === 410) {\n throw new TempestApiError({\n status: response.status,\n detail: \"O upload expirou no servidor. Comece de novo.\",\n });\n }\n const confirmed = parseOffset(response);\n if (confirmed === null) throw failed(response, \"HEAD sem Upload-Offset.\");\n return confirmed;\n }\n\n /**\n * Re-attach to a persisted upload, or create a new one.\n *\n * The persisted record is only trusted when the file size still matches, and the\n * offset it holds is re-checked with `HEAD` — the client's copy can be ahead of\n * the server's whenever the last response was lost.\n */\n async function ensureUpload(): Promise<string> {\n const stored = storage ? await storage.get(key) : null;\n if (stored && stored.size === file.size) {\n idempotencyKey = stored.idempotencyKey;\n if (stored.url) {\n try {\n offset = await probe(stored.url);\n url = stored.url;\n return stored.url;\n } catch {\n offset = 0;\n }\n }\n }\n\n setState(\"creating\");\n idempotencyKey ??= generateIdempotencyKey();\n url = null;\n offset = 0;\n if (storage) {\n await storage.set(key, {\n url: \"\",\n offset: 0,\n size: file.size,\n idempotencyKey,\n updatedAt: Date.now(),\n });\n }\n\n const creationHeaders: Record<string, string> = {\n ...baseHeaders(),\n \"Upload-Length\": String(file.size),\n \"Idempotency-Key\": idempotencyKey,\n };\n const encoded = encodeMetadata(metadata);\n if (encoded) creationHeaders[\"Upload-Metadata\"] = encoded;\n\n const response = await sendRequest({\n method: \"POST\",\n url: endpoint,\n headers: creationHeaders,\n withCredentials,\n register,\n });\n if (response.status !== 201) throw failed(response, \"Criação do upload recusada.\");\n const locationHeader = response.header(\"Location\");\n if (!locationHeader) throw failed(response, \"Criação do upload sem cabeçalho Location.\");\n\n url = resolveUploadUrl(locationHeader);\n await persist();\n return url;\n }\n\n /** Push one chunk, resyncing the offset first when a previous attempt failed. */\n async function writeChunk(target: string, resync: { needed: boolean }): Promise<void> {\n if (resync.needed) {\n offset = await probe(target);\n resync.needed = false;\n report(offset);\n await persist();\n if (offset >= file.size) return;\n }\n\n const end = Math.min(offset + chunkSize, file.size);\n const from = offset;\n const response = await sendRequest({\n method: \"PATCH\",\n url: target,\n headers: {\n ...baseHeaders(),\n \"Content-Type\": \"application/offset+octet-stream\",\n \"Upload-Offset\": String(from),\n },\n body: file.slice(from, end),\n withCredentials,\n onProgress: (loaded) => report(Math.min(from + loaded, file.size)),\n register,\n });\n\n if (response.status === 409 || response.status === 412) {\n resync.needed = true;\n throw failed(response, \"Offset divergente — o servidor já tinha esses bytes.\");\n }\n if (response.status !== 204 && response.status !== 200) {\n throw failed(response, \"Chunk recusado pelo servidor.\");\n }\n\n offset = parseOffset(response) ?? end;\n report(offset);\n await persist();\n }\n\n /**\n * Drive the whole upload: attach or create, then chunk until complete.\n *\n * The `shouldRetry` predicate does double duty — besides deciding, it arms\n * `resync` so the next attempt re-reads the server's offset with `HEAD` before\n * writing. That is deliberate: it is the one place that sees *every* chunk\n * failure, whatever the cause, and after any failure the client's idea of the\n * offset is exactly what cannot be trusted.\n *\n * @returns The result, or `null` when `pause`/`abort` stopped the run.\n */\n async function run(): Promise<ResumableUploadResult | null> {\n stopping = null;\n const target = await ensureUpload();\n resumedFrom = offset;\n setState(\"uploading\");\n report(offset);\n\n const resync = { needed: false };\n while (offset < file.size) {\n if (stopping) break;\n await retry(() => writeChunk(target, resync), {\n retries: 5,\n ...retryOptions,\n shouldRetry: (error, attempt) => {\n if (stopping) return false;\n if (error instanceof DOMException && error.name === \"AbortError\") return false;\n resync.needed = true;\n return retryOptions?.shouldRetry?.(error, attempt) ?? true;\n },\n });\n }\n\n if (stopping === \"pause\") {\n setState(\"paused\");\n return null;\n }\n if (stopping === \"abort\") {\n setState(\"aborted\");\n return null;\n }\n\n setState(\"done\");\n if (storage) await storage.delete(key);\n return { url: target, size: file.size };\n }\n\n async function guarded(): Promise<ResumableUploadResult | null> {\n try {\n return await run();\n } catch (error) {\n if (\n stopping !== null ||\n (error instanceof DOMException && error.name === \"AbortError\")\n ) {\n setState(stopping === \"abort\" ? \"aborted\" : \"paused\");\n return null;\n }\n setState(\"error\");\n throw error;\n } finally {\n inFlight = null;\n }\n }\n\n function stop(reason: \"pause\" | \"abort\"): void {\n stopping = reason;\n inFlight?.abort();\n inFlight = null;\n }\n\n return {\n start: guarded,\n resume: guarded,\n pause: () => stop(\"pause\"),\n abort: async ({ discard = false } = {}) => {\n stop(\"abort\");\n setState(\"aborted\");\n if (!discard) return;\n if (url) {\n await sendRequest({\n method: \"DELETE\",\n url,\n headers: baseHeaders(),\n withCredentials,\n register: () => undefined,\n }).catch(() => undefined);\n }\n if (storage) await storage.delete(key);\n },\n get state() {\n return state;\n },\n get offset() {\n return offset;\n },\n get url() {\n return url;\n },\n key,\n };\n}\n"],"mappings":"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"}
|