ezshare-cli 1.0.0
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/LICENSE +21 -0
- package/README.md +311 -0
- package/bin/ezshare.js +2 -0
- package/dist/cli.js +60 -0
- package/dist/commands/receive.js +129 -0
- package/dist/commands/send.js +117 -0
- package/dist/components/FileBrowser.js +75 -0
- package/dist/components/HelpScreen.js +10 -0
- package/dist/components/MainMenu.js +9 -0
- package/dist/components/Shell.js +88 -0
- package/dist/components/TransferUI.js +6 -0
- package/dist/utils/compression.js +354 -0
- package/dist/utils/crypto.js +236 -0
- package/dist/utils/fileSystem.js +89 -0
- package/dist/utils/network.js +98 -0
- package/dist/utils/tar.js +169 -0
- package/package.json +56 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import { dirname } from 'node:path';
|
|
5
|
+
import { readDirectory, formatFileSize } from '../utils/fileSystem.js';
|
|
6
|
+
export function FileBrowser({ initialPath = process.cwd(), onSelect, onCancel, }) {
|
|
7
|
+
const [currentPath, setCurrentPath] = useState(initialPath);
|
|
8
|
+
const [files, setFiles] = useState([]);
|
|
9
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
10
|
+
const [loading, setLoading] = useState(true);
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
loadDirectory(currentPath);
|
|
13
|
+
}, [currentPath]);
|
|
14
|
+
const loadDirectory = async (path) => {
|
|
15
|
+
setLoading(true);
|
|
16
|
+
try {
|
|
17
|
+
const entries = await readDirectory(path);
|
|
18
|
+
// Add ".." entry to go up one level (unless we're at root)
|
|
19
|
+
const parentDir = dirname(path);
|
|
20
|
+
if (parentDir !== path) {
|
|
21
|
+
entries.unshift({
|
|
22
|
+
name: '..',
|
|
23
|
+
path: parentDir,
|
|
24
|
+
isDirectory: true,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
setFiles(entries);
|
|
28
|
+
setSelectedIndex(0);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
console.error('Error loading directory:', error);
|
|
32
|
+
setFiles([]);
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
setLoading(false);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
useInput((input, key) => {
|
|
39
|
+
if (loading)
|
|
40
|
+
return; // Ignore input while loading
|
|
41
|
+
if (key.upArrow) {
|
|
42
|
+
setSelectedIndex((prev) => Math.max(0, prev - 1));
|
|
43
|
+
}
|
|
44
|
+
else if (key.downArrow) {
|
|
45
|
+
setSelectedIndex((prev) => Math.min(files.length - 1, prev + 1));
|
|
46
|
+
}
|
|
47
|
+
else if (key.return) {
|
|
48
|
+
const selected = files[selectedIndex];
|
|
49
|
+
if (selected) {
|
|
50
|
+
if (selected.isDirectory) {
|
|
51
|
+
// Navigate into directory
|
|
52
|
+
setCurrentPath(selected.path);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
// File selected!
|
|
56
|
+
onSelect(selected.path);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
else if (key.escape) {
|
|
61
|
+
onCancel();
|
|
62
|
+
}
|
|
63
|
+
else if (input === 's' && selectedIndex < files.length) {
|
|
64
|
+
// Quick shortcut: 's' to select current file/folder for sending
|
|
65
|
+
const selected = files[selectedIndex];
|
|
66
|
+
if (selected && selected.name !== '..') {
|
|
67
|
+
onSelect(selected.path);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
if (loading) {
|
|
72
|
+
return (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { children: "Loading directory..." }) }));
|
|
73
|
+
}
|
|
74
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "cyan", children: ["Current: ", currentPath] }), _jsx(Text, { dimColor: true, children: "\u2191\u2193 to navigate | Enter to open/select | 's' to select | Esc to cancel" }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: files.length === 0 ? (_jsx(Text, { dimColor: true, children: "Empty directory" })) : (files.map((file, idx) => (_jsx(Box, { children: _jsxs(Text, { color: idx === selectedIndex ? 'green' : undefined, children: [idx === selectedIndex ? '> ' : ' ', file.isDirectory ? '📁 ' : '📄 ', file.name, file.size !== undefined ? ` (${formatFileSize(file.size)})` : ''] }) }, file.path)))) }), files.length > 10 && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Showing ", selectedIndex + 1, " of ", files.length, " items"] }) }))] }));
|
|
75
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useInput } from 'ink';
|
|
3
|
+
export function HelpScreen({ onBack }) {
|
|
4
|
+
useInput((input, key) => {
|
|
5
|
+
if (key.escape || input === 'q') {
|
|
6
|
+
onBack();
|
|
7
|
+
}
|
|
8
|
+
});
|
|
9
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "EzShare CLI - Help" }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Slash Commands:" }), _jsx(Text, { children: " /ezshare - Start interactive file transfer" }), _jsx(Text, { children: " /help - Show this help screen" }), _jsx(Text, { children: " /exit - Exit the shell" })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Direct CLI Mode:" }), _jsx(Text, { children: " hyperstream send <path> - Send file/folder" }), _jsx(Text, { children: " hyperstream receive <key> [-o dir] - Receive file/folder" })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Keyboard Shortcuts:" }), _jsx(Text, { children: " \u2191\u2193 - Navigate menus/files" }), _jsx(Text, { children: " Enter - Select/Confirm" }), _jsx(Text, { children: " Esc - Cancel/Go back" }), _jsx(Text, { children: " q - Quit (from command mode)" })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "How It Works:" }), _jsx(Text, { children: " 1. Sender selects a file/folder to share" }), _jsx(Text, { children: " 2. A unique share key is generated" }), _jsx(Text, { children: " 3. Receiver enters the share key" }), _jsx(Text, { children: " 4. Files are transferred peer-to-peer (P2P)" }), _jsx(Text, { children: " 5. Data is encrypted end-to-end with AES-256-GCM" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press Esc or 'q' to return" }) })] }));
|
|
10
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { Select } from '@inkjs/ui';
|
|
4
|
+
export function MainMenu({ onSelect }) {
|
|
5
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "What would you like to do?" }), _jsx(Box, { marginTop: 1, children: _jsx(Select, { options: [
|
|
6
|
+
{ label: '📤 Send file or folder', value: 'send' },
|
|
7
|
+
{ label: '📥 Receive file or folder', value: 'receive' },
|
|
8
|
+
], onChange: (value) => onSelect(value) }) })] }));
|
|
9
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import { TextInput } from '@inkjs/ui';
|
|
5
|
+
import { HelpScreen } from './HelpScreen.js';
|
|
6
|
+
import { MainMenu } from './MainMenu.js';
|
|
7
|
+
import { FileBrowser } from './FileBrowser.js';
|
|
8
|
+
import { SendCommand } from '../commands/send.js';
|
|
9
|
+
import { ReceiveCommand } from '../commands/receive.js';
|
|
10
|
+
export function Shell() {
|
|
11
|
+
const [state, setState] = useState({
|
|
12
|
+
mode: 'command',
|
|
13
|
+
history: [],
|
|
14
|
+
});
|
|
15
|
+
const handleCommand = (cmd) => {
|
|
16
|
+
const trimmed = cmd.trim();
|
|
17
|
+
// Add to history
|
|
18
|
+
setState((prev) => ({
|
|
19
|
+
...prev,
|
|
20
|
+
history: [...prev.history, trimmed],
|
|
21
|
+
}));
|
|
22
|
+
// Process slash commands
|
|
23
|
+
if (trimmed === '/ezshare') {
|
|
24
|
+
setState((prev) => ({ ...prev, mode: 'main-menu' }));
|
|
25
|
+
}
|
|
26
|
+
else if (trimmed === '/help') {
|
|
27
|
+
setState((prev) => ({ ...prev, mode: 'help' }));
|
|
28
|
+
}
|
|
29
|
+
else if (trimmed === '/exit') {
|
|
30
|
+
process.exit(0);
|
|
31
|
+
}
|
|
32
|
+
else if (trimmed.startsWith('/')) {
|
|
33
|
+
// Unknown command
|
|
34
|
+
setState((prev) => ({
|
|
35
|
+
...prev,
|
|
36
|
+
history: [...prev.history, `Unknown command: ${trimmed}`],
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const handleMenuSelect = (choice) => {
|
|
41
|
+
if (choice === 'send') {
|
|
42
|
+
setState((prev) => ({ ...prev, mode: 'file-browser', transferMode: 'send' }));
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
setState((prev) => ({ ...prev, mode: 'receive-key', transferMode: 'receive' }));
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
const handleFileSelect = (path) => {
|
|
49
|
+
setState((prev) => ({
|
|
50
|
+
...prev,
|
|
51
|
+
mode: 'transfer',
|
|
52
|
+
selectedPath: path,
|
|
53
|
+
}));
|
|
54
|
+
};
|
|
55
|
+
const handleKeyInput = (key) => {
|
|
56
|
+
setState((prev) => ({
|
|
57
|
+
...prev,
|
|
58
|
+
mode: 'transfer',
|
|
59
|
+
transferKey: key,
|
|
60
|
+
}));
|
|
61
|
+
};
|
|
62
|
+
const handleTransferComplete = () => {
|
|
63
|
+
setState((prev) => ({ ...prev, mode: 'done' }));
|
|
64
|
+
};
|
|
65
|
+
const handleTransferError = (error) => {
|
|
66
|
+
console.error('Transfer error:', error);
|
|
67
|
+
setState((prev) => ({ ...prev, mode: 'done' }));
|
|
68
|
+
};
|
|
69
|
+
const handleCancel = () => {
|
|
70
|
+
setState((prev) => ({ ...prev, mode: 'command' }));
|
|
71
|
+
};
|
|
72
|
+
const handleHelpBack = () => {
|
|
73
|
+
setState((prev) => ({ ...prev, mode: 'command' }));
|
|
74
|
+
};
|
|
75
|
+
// Global keyboard shortcuts
|
|
76
|
+
useInput((input, key) => {
|
|
77
|
+
if (input === 'q' && state.mode === 'command') {
|
|
78
|
+
process.exit(0);
|
|
79
|
+
}
|
|
80
|
+
if (key.escape && state.mode !== 'transfer') {
|
|
81
|
+
setState((prev) => ({ ...prev, mode: 'command' }));
|
|
82
|
+
}
|
|
83
|
+
if (key.escape && state.mode === 'done') {
|
|
84
|
+
setState((prev) => ({ ...prev, mode: 'command' }));
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "EzShare CLI - P2P File Transfer" }), _jsx(Text, { dimColor: true, children: "Type /ezshare to start or /help for commands" }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [state.mode === 'command' && (_jsx(TextInput, { placeholder: "Enter command...", onSubmit: handleCommand })), state.mode === 'main-menu' && _jsx(MainMenu, { onSelect: handleMenuSelect }), state.mode === 'file-browser' && (_jsx(FileBrowser, { onSelect: handleFileSelect, onCancel: handleCancel })), state.mode === 'receive-key' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Paste the share key you received:" }), _jsx(TextInput, { placeholder: "Enter share key...", onSubmit: handleKeyInput }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press Esc to cancel" }) })] })), state.mode === 'transfer' && state.transferMode === 'send' && state.selectedPath && (_jsx(SendCommand, { path: state.selectedPath, onComplete: handleTransferComplete, onError: handleTransferError })), state.mode === 'transfer' && state.transferMode === 'receive' && state.transferKey && (_jsx(ReceiveCommand, { shareKey: state.transferKey, onComplete: handleTransferComplete, onError: handleTransferError })), state.mode === 'done' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "green", bold: true, children: "\u2713 Transfer complete!" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press Esc to return to command mode" }) })] })), state.mode === 'help' && _jsx(HelpScreen, { onBack: handleHelpBack })] }), state.history.length > 0 && state.mode === 'command' && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Recent commands:" }), state.history.slice(-3).map((cmd, idx) => (_jsxs(Text, { dimColor: true, children: [' ', cmd] }, `cmd-${idx}-${cmd}`)))] }))] }));
|
|
88
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { Spinner, ProgressBar } from '@inkjs/ui';
|
|
4
|
+
export function TransferUI({ mode, fileName, fileSize, progress, shareKey, }) {
|
|
5
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Text, { bold: true, color: mode === 'send' ? 'green' : 'blue', children: [mode === 'send' ? '📤 Sending' : '📥 Receiving', " ", fileName || 'file'] }), mode === 'send' && shareKey && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "cyan", children: "Share this key with receiver:" }), _jsx(Text, { bold: true, children: shareKey }), progress === 0 && (_jsx(Box, { marginTop: 1, children: _jsx(Spinner, { label: "Waiting for peer to connect..." }) }))] })), mode === 'receive' && progress === 0 && (_jsx(Box, { marginTop: 1, children: _jsx(Spinner, { label: "Connecting to peer..." }) })), progress > 0 && progress < 100 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(ProgressBar, { value: progress }), _jsxs(Text, { children: [" ", progress, "%"] })] })), progress === 100 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "green", bold: true, children: "\u2713 Transfer complete!" }), _jsx(Text, { dimColor: true, children: "Press Esc to return to command mode" })] }))] }));
|
|
6
|
+
}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compression utilities for HyperStream
|
|
3
|
+
*
|
|
4
|
+
* Uses zstd for streaming compression with adaptive detection
|
|
5
|
+
* to skip already-compressed files.
|
|
6
|
+
*
|
|
7
|
+
* Stream format:
|
|
8
|
+
* [1 byte: flag] [payload...]
|
|
9
|
+
*
|
|
10
|
+
* flag = 0x00: raw data (no compression applied)
|
|
11
|
+
* flag = 0x01: zstd compressed data
|
|
12
|
+
*
|
|
13
|
+
* The flag makes streams self-describing, allowing the receiver
|
|
14
|
+
* to automatically detect and handle both cases.
|
|
15
|
+
*/
|
|
16
|
+
import { Transform } from 'node:stream';
|
|
17
|
+
import { extname } from 'node:path';
|
|
18
|
+
import { execSync } from 'node:child_process';
|
|
19
|
+
import { compress, decompress } from 'simple-zstd';
|
|
20
|
+
// Protocol flags
|
|
21
|
+
const FLAG_RAW = 0x00;
|
|
22
|
+
const FLAG_COMPRESSED = 0x01;
|
|
23
|
+
// Default zstd compression level (1-22, 3 is default, good balance)
|
|
24
|
+
const COMPRESSION_LEVEL = 3;
|
|
25
|
+
/**
|
|
26
|
+
* File extensions that are already compressed.
|
|
27
|
+
* Compressing these again wastes CPU with minimal size benefit.
|
|
28
|
+
*/
|
|
29
|
+
const COMPRESSED_EXTENSIONS = new Set([
|
|
30
|
+
// Archives
|
|
31
|
+
'.zip',
|
|
32
|
+
'.gz',
|
|
33
|
+
'.bz2',
|
|
34
|
+
'.xz',
|
|
35
|
+
'.7z',
|
|
36
|
+
'.rar',
|
|
37
|
+
'.zst',
|
|
38
|
+
'.lz4',
|
|
39
|
+
'.lzma',
|
|
40
|
+
'.tgz',
|
|
41
|
+
'.tbz2',
|
|
42
|
+
// Images
|
|
43
|
+
'.jpg',
|
|
44
|
+
'.jpeg',
|
|
45
|
+
'.png',
|
|
46
|
+
'.gif',
|
|
47
|
+
'.webp',
|
|
48
|
+
'.avif',
|
|
49
|
+
'.heic',
|
|
50
|
+
'.heif',
|
|
51
|
+
'.ico',
|
|
52
|
+
// Video
|
|
53
|
+
'.mp4',
|
|
54
|
+
'.mkv',
|
|
55
|
+
'.avi',
|
|
56
|
+
'.mov',
|
|
57
|
+
'.webm',
|
|
58
|
+
'.m4v',
|
|
59
|
+
'.wmv',
|
|
60
|
+
'.flv',
|
|
61
|
+
'.m2ts',
|
|
62
|
+
// Audio
|
|
63
|
+
'.mp3',
|
|
64
|
+
'.aac',
|
|
65
|
+
'.flac',
|
|
66
|
+
'.ogg',
|
|
67
|
+
'.m4a',
|
|
68
|
+
'.wma',
|
|
69
|
+
'.opus',
|
|
70
|
+
// Documents (Office formats use ZIP internally)
|
|
71
|
+
'.pdf',
|
|
72
|
+
'.docx',
|
|
73
|
+
'.xlsx',
|
|
74
|
+
'.pptx',
|
|
75
|
+
'.odt',
|
|
76
|
+
'.ods',
|
|
77
|
+
'.odp',
|
|
78
|
+
'.epub',
|
|
79
|
+
// Web assets
|
|
80
|
+
'.woff',
|
|
81
|
+
'.woff2',
|
|
82
|
+
'.br', // brotli
|
|
83
|
+
]);
|
|
84
|
+
/**
|
|
85
|
+
* Check if the system has zstd installed
|
|
86
|
+
*/
|
|
87
|
+
export function isZstdAvailable() {
|
|
88
|
+
try {
|
|
89
|
+
execSync('zstd --version', { stdio: 'ignore' });
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Check if a file should be compressed based on its extension.
|
|
98
|
+
*
|
|
99
|
+
* Returns false for files that are already compressed (images, videos,
|
|
100
|
+
* archives, etc.) since re-compressing them wastes CPU with minimal benefit.
|
|
101
|
+
*
|
|
102
|
+
* @param filePath - Path to the file (only extension is checked)
|
|
103
|
+
* @returns true if the file should be compressed
|
|
104
|
+
*/
|
|
105
|
+
export function shouldCompress(filePath) {
|
|
106
|
+
const ext = extname(filePath).toLowerCase();
|
|
107
|
+
return !COMPRESSED_EXTENSIONS.has(ext);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Create a transform that prepends a flag byte to the stream.
|
|
111
|
+
*/
|
|
112
|
+
function createFlagPrepender(flag) {
|
|
113
|
+
let flagSent = false;
|
|
114
|
+
return new Transform({
|
|
115
|
+
transform(chunk, _encoding, callback) {
|
|
116
|
+
if (!flagSent) {
|
|
117
|
+
this.push(Buffer.from([flag]));
|
|
118
|
+
flagSent = true;
|
|
119
|
+
}
|
|
120
|
+
this.push(chunk);
|
|
121
|
+
callback();
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Create a compression stream.
|
|
127
|
+
*
|
|
128
|
+
* When compress=true:
|
|
129
|
+
* - Outputs [0x01][zstd-compressed-data]
|
|
130
|
+
* - Uses zstd for compression
|
|
131
|
+
*
|
|
132
|
+
* When compress=false:
|
|
133
|
+
* - Outputs [0x00][raw-data]
|
|
134
|
+
* - Simple passthrough with flag prefix
|
|
135
|
+
*
|
|
136
|
+
* @param shouldCompress - Whether to actually compress (default: true)
|
|
137
|
+
* @returns Promise resolving to a Transform stream
|
|
138
|
+
* @throws Error if zstd is not available and compression is requested
|
|
139
|
+
*/
|
|
140
|
+
export async function createCompressStream(shouldCompress = true) {
|
|
141
|
+
if (!shouldCompress) {
|
|
142
|
+
// Simple passthrough with raw flag
|
|
143
|
+
return createFlagPrepender(FLAG_RAW);
|
|
144
|
+
}
|
|
145
|
+
// Check zstd availability
|
|
146
|
+
if (!isZstdAvailable()) {
|
|
147
|
+
throw new Error('zstd is not installed. Please install zstd:\n' +
|
|
148
|
+
' Ubuntu/Debian: sudo apt install zstd\n' +
|
|
149
|
+
' macOS: brew install zstd\n' +
|
|
150
|
+
' Windows: choco install zstd');
|
|
151
|
+
}
|
|
152
|
+
// Get zstd compression stream
|
|
153
|
+
const zstdStream = await compress(COMPRESSION_LEVEL);
|
|
154
|
+
// Track state
|
|
155
|
+
let flagSent = false;
|
|
156
|
+
const pendingData = [];
|
|
157
|
+
let zstdFinished = false;
|
|
158
|
+
let flushCallback = null;
|
|
159
|
+
// Collect compressed output from zstd
|
|
160
|
+
zstdStream.on('data', (chunk) => {
|
|
161
|
+
pendingData.push(chunk);
|
|
162
|
+
});
|
|
163
|
+
zstdStream.on('end', () => {
|
|
164
|
+
zstdFinished = true;
|
|
165
|
+
if (flushCallback) {
|
|
166
|
+
const cb = flushCallback;
|
|
167
|
+
flushCallback = null;
|
|
168
|
+
cb();
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
zstdStream.on('error', (err) => {
|
|
172
|
+
if (flushCallback) {
|
|
173
|
+
const cb = flushCallback;
|
|
174
|
+
flushCallback = null;
|
|
175
|
+
cb(err);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
const wrapper = new Transform({
|
|
179
|
+
transform(chunk, _encoding, callback) {
|
|
180
|
+
// Write input to zstd
|
|
181
|
+
zstdStream.write(chunk, (err) => {
|
|
182
|
+
if (err) {
|
|
183
|
+
callback(err);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
// Push any pending compressed data
|
|
187
|
+
while (pendingData.length > 0) {
|
|
188
|
+
const data = pendingData.shift();
|
|
189
|
+
if (!flagSent) {
|
|
190
|
+
this.push(Buffer.from([FLAG_COMPRESSED]));
|
|
191
|
+
flagSent = true;
|
|
192
|
+
}
|
|
193
|
+
this.push(data);
|
|
194
|
+
}
|
|
195
|
+
callback();
|
|
196
|
+
});
|
|
197
|
+
},
|
|
198
|
+
flush(callback) {
|
|
199
|
+
// End the zstd stream and wait for all data
|
|
200
|
+
const pushRemaining = () => {
|
|
201
|
+
while (pendingData.length > 0) {
|
|
202
|
+
const data = pendingData.shift();
|
|
203
|
+
if (!flagSent) {
|
|
204
|
+
this.push(Buffer.from([FLAG_COMPRESSED]));
|
|
205
|
+
flagSent = true;
|
|
206
|
+
}
|
|
207
|
+
this.push(data);
|
|
208
|
+
}
|
|
209
|
+
// Handle edge case: empty input
|
|
210
|
+
if (!flagSent) {
|
|
211
|
+
this.push(Buffer.from([FLAG_COMPRESSED]));
|
|
212
|
+
}
|
|
213
|
+
callback();
|
|
214
|
+
};
|
|
215
|
+
// Wait for 'end' event which fires after all 'data' events
|
|
216
|
+
zstdStream.once('end', pushRemaining);
|
|
217
|
+
zstdStream.end();
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
return wrapper;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Create a decompression stream.
|
|
224
|
+
*
|
|
225
|
+
* Automatically detects the compression format based on the first byte:
|
|
226
|
+
* - 0x00: passthrough (no decompression)
|
|
227
|
+
* - 0x01: zstd decompression
|
|
228
|
+
*
|
|
229
|
+
* @returns Promise resolving to a Transform stream
|
|
230
|
+
*/
|
|
231
|
+
export async function createDecompressStream() {
|
|
232
|
+
let flag = null;
|
|
233
|
+
let zstdStream = null;
|
|
234
|
+
let buffer = Buffer.alloc(0);
|
|
235
|
+
let destroyed = false;
|
|
236
|
+
const pendingOutput = [];
|
|
237
|
+
const wrapper = new Transform({
|
|
238
|
+
transform(chunk, _encoding, callback) {
|
|
239
|
+
if (destroyed) {
|
|
240
|
+
return callback();
|
|
241
|
+
}
|
|
242
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
243
|
+
// Read flag byte if not yet read
|
|
244
|
+
if (flag === null) {
|
|
245
|
+
if (buffer.length < 1) {
|
|
246
|
+
return callback(); // Need more data
|
|
247
|
+
}
|
|
248
|
+
flag = buffer[0];
|
|
249
|
+
buffer = buffer.subarray(1);
|
|
250
|
+
// Validate flag
|
|
251
|
+
if (flag !== FLAG_RAW && flag !== FLAG_COMPRESSED) {
|
|
252
|
+
return callback(new Error(`Invalid compression flag: 0x${flag.toString(16)}`));
|
|
253
|
+
}
|
|
254
|
+
// Initialize zstd if needed
|
|
255
|
+
if (flag === FLAG_COMPRESSED) {
|
|
256
|
+
if (!isZstdAvailable()) {
|
|
257
|
+
return callback(new Error('zstd is not installed but data is compressed'));
|
|
258
|
+
}
|
|
259
|
+
// Synchronously set up - decompress() returns Promise but we handle async carefully
|
|
260
|
+
decompress()
|
|
261
|
+
.then((stream) => {
|
|
262
|
+
if (destroyed) {
|
|
263
|
+
stream.destroy();
|
|
264
|
+
return callback();
|
|
265
|
+
}
|
|
266
|
+
zstdStream = stream;
|
|
267
|
+
// Collect decompressed output
|
|
268
|
+
stream.on('data', (data) => {
|
|
269
|
+
pendingOutput.push(data);
|
|
270
|
+
});
|
|
271
|
+
stream.on('error', (err) => {
|
|
272
|
+
wrapper.destroy(err);
|
|
273
|
+
});
|
|
274
|
+
// Write any buffered compressed data
|
|
275
|
+
if (buffer.length > 0) {
|
|
276
|
+
stream.write(buffer, (err) => {
|
|
277
|
+
buffer = Buffer.alloc(0);
|
|
278
|
+
// Push any output that arrived
|
|
279
|
+
while (pendingOutput.length > 0) {
|
|
280
|
+
this.push(pendingOutput.shift());
|
|
281
|
+
}
|
|
282
|
+
callback(err || undefined);
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
callback();
|
|
287
|
+
}
|
|
288
|
+
})
|
|
289
|
+
.catch((err) => {
|
|
290
|
+
callback(err);
|
|
291
|
+
});
|
|
292
|
+
return; // Wait for async initialization
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
// Process buffered data
|
|
296
|
+
if (buffer.length > 0) {
|
|
297
|
+
if (flag === FLAG_COMPRESSED && zstdStream) {
|
|
298
|
+
zstdStream.write(buffer, (err) => {
|
|
299
|
+
buffer = Buffer.alloc(0);
|
|
300
|
+
// Push any output that arrived
|
|
301
|
+
while (pendingOutput.length > 0) {
|
|
302
|
+
this.push(pendingOutput.shift());
|
|
303
|
+
}
|
|
304
|
+
if (err && !destroyed) {
|
|
305
|
+
callback(err);
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
callback();
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
else if (flag === FLAG_RAW) {
|
|
313
|
+
this.push(buffer);
|
|
314
|
+
buffer = Buffer.alloc(0);
|
|
315
|
+
callback();
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
callback();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
// Push any pending output
|
|
323
|
+
while (pendingOutput.length > 0) {
|
|
324
|
+
this.push(pendingOutput.shift());
|
|
325
|
+
}
|
|
326
|
+
callback();
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
flush(callback) {
|
|
330
|
+
if (zstdStream) {
|
|
331
|
+
// Wait for 'end' event which fires after all 'data' events
|
|
332
|
+
zstdStream.once('end', () => {
|
|
333
|
+
// Push any remaining output
|
|
334
|
+
while (pendingOutput.length > 0) {
|
|
335
|
+
this.push(pendingOutput.shift());
|
|
336
|
+
}
|
|
337
|
+
callback();
|
|
338
|
+
});
|
|
339
|
+
zstdStream.end();
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
callback();
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
destroy(err, callback) {
|
|
346
|
+
destroyed = true;
|
|
347
|
+
if (zstdStream) {
|
|
348
|
+
zstdStream.destroy();
|
|
349
|
+
}
|
|
350
|
+
callback(err);
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
return wrapper;
|
|
354
|
+
}
|