Skip to content

Commit e8b4b8d

Browse files
authored
Add CallbackStreamWrapper for custom ZIP output (#363)
* Add CallbackStreamWrapper for custom ZIP output (#199) * Refactor CallbackOutputTest to use RuntimeException directly and update file permissions notation * Enhance CallbackStreamWrapper with destructor for cleanup and update exception handling * Fix StreamOutput documentation examples - Replace progress echo with reportProgress() function call in Example 2 - Replace callback-based base64 with PHP stream filters in Example 3
1 parent c15a72e commit e8b4b8d

File tree

5 files changed

+561
-0
lines changed

5 files changed

+561
-0
lines changed

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,38 @@ $zip->addFileFromPath(
6464
$zip->finish();
6565
```
6666

67+
### Callback Output
68+
69+
You can stream ZIP data to a custom callback function instead of directly to the browser:
70+
71+
```php
72+
use ZipStream\ZipStream;
73+
use ZipStream\Stream\CallbackStreamWrapper;
74+
75+
// Stream to a callback function with proper file handling
76+
$outputFile = fopen('output.zip', 'wb');
77+
$backupFile = fopen('backup.zip', 'wb');
78+
79+
$zip = new ZipStream(
80+
outputStream: CallbackStreamWrapper::open(function (string $data) use ($outputFile, $backupFile) {
81+
// Handle ZIP data as it's generated
82+
fwrite($outputFile, $data);
83+
84+
// Send to multiple destinations efficiently
85+
echo $data; // Browser
86+
fwrite($backupFile, $data); // Backup file
87+
}),
88+
sendHttpHeaders: false,
89+
);
90+
91+
$zip->addFile('hello.txt', 'Hello World!');
92+
$zip->finish();
93+
94+
// Clean up resources
95+
fclose($outputFile);
96+
fclose($backupFile);
97+
```
98+
6799
## Questions
68100

69101
**💬 Questions? Please Read This First!**

guides/Options.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ Here is the full list of options available to you. You can also have a look at
1717
//
1818
// Setup with `psr/http-message` & `guzzlehttp/psr7` dependencies
1919
// required when using `Psr\Http\Message\StreamInterface`.
20+
//
21+
// Can also use CallbackStreamWrapper for custom output handling:
22+
// outputStream: CallbackStreamWrapper::open(function($data) { /* handle data */ }),
2023
outputStream: $filePointer,
2124
2225
// Set the deflate level (default is 6; use -1 to disable it)

guides/StreamOutput.rst

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,77 @@ Stream to S3 Bucket
3737
$zip->finish();
3838
3939
fclose($zipFile);
40+
41+
Stream to Callback Function
42+
---------------------------
43+
44+
The CallbackStreamWrapper allows you to stream ZIP data to a custom callback function,
45+
enabling flexible output handling such as streaming to multiple destinations,
46+
progress tracking, or data transformation.
47+
48+
.. code-block:: php
49+
50+
use ZipStream\ZipStream;
51+
use ZipStream\Stream\CallbackStreamWrapper;
52+
53+
// Example 1: Stream to multiple destinations with proper file handling
54+
$backupFile = fopen('backup.zip', 'wb');
55+
$logFile = fopen('transfer.log', 'ab');
56+
57+
$zip = new ZipStream(
58+
outputStream: CallbackStreamWrapper::open(function (string $data) use ($backupFile, $logFile) {
59+
// Send to browser
60+
echo $data;
61+
62+
// Save to file efficiently
63+
fwrite($backupFile, $data);
64+
65+
// Log transfer progress
66+
fwrite($logFile, "Transferred " . strlen($data) . " bytes\n");
67+
}),
68+
sendHttpHeaders: false,
69+
);
70+
71+
$zip->addFile('hello.txt', 'Hello World!');
72+
$zip->finish();
73+
74+
// Clean up resources
75+
fclose($backupFile);
76+
fclose($logFile);
77+
78+
.. code-block:: php
79+
80+
// Example 2: Progress tracking
81+
$totalBytes = 0;
82+
$zip = new ZipStream(
83+
outputStream: CallbackStreamWrapper::open(function (string $data) use (&$totalBytes) {
84+
$totalBytes += strlen($data);
85+
reportProgress($totalBytes); // Report progress to your tracking system
86+
87+
// Your actual output handling
88+
echo $data;
89+
}),
90+
sendHttpHeaders: false,
91+
);
92+
93+
$zip->addFile('large_file.txt', str_repeat('A', 10000));
94+
$zip->finish();
95+
96+
.. code-block:: php
97+
98+
// Example 3: Data transformation using PHP stream filters
99+
// For data transformations, prefer PHP's built-in stream filters
100+
$outputStream = fopen('php://output', 'w');
101+
stream_filter_append($outputStream, 'convert.base64-encode');
102+
103+
$zip = new ZipStream(
104+
outputStream: $outputStream,
105+
sendHttpHeaders: false,
106+
);
107+
108+
$zip->addFile('secret.txt', 'Confidential data');
109+
$zip->finish();
110+
fclose($outputStream);
111+
112+
.. note::
113+
For data transformations, PHP's built-in stream filters are preferred over callback transformations. Stream filters operate at the stream level and maintain data integrity. You can register custom filters using ``stream_filter_register()`` for specialized transformations.

src/Stream/CallbackStreamWrapper.php

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace ZipStream\Stream;
6+
7+
use RuntimeException;
8+
use Throwable;
9+
10+
/**
11+
* Stream wrapper that allows writing data to a callback function.
12+
*
13+
* This wrapper creates a virtual stream that forwards all written data
14+
* to a provided callback function, enabling custom output handling
15+
* such as streaming to HTTP responses, files, or other destinations.
16+
*
17+
* @psalm-suppress UnusedClass Used dynamically through stream_wrapper_register
18+
*/
19+
final class CallbackStreamWrapper
20+
{
21+
public const PROTOCOL = 'zipcb';
22+
23+
/** @var array<string, callable(string):void> Map of stream IDs to callback functions */
24+
private static array $callbacks = [];
25+
26+
/** @var string|null Unique identifier for this stream instance */
27+
private ?string $id = null;
28+
29+
/** @var int Current position in the stream */
30+
private int $pos = 0;
31+
32+
/**
33+
* Destructor - ensures cleanup even if stream_close() isn't called.
34+
* Prevents memory leaks in long-running processes.
35+
*/
36+
public function __destruct()
37+
{
38+
$this->stream_close();
39+
}
40+
41+
/**
42+
* Create a new callback stream.
43+
*
44+
* @param callable(string):void $callback Function to call with written data
45+
* @return resource|false Stream resource or false on failure
46+
*/
47+
public static function open(callable $callback)
48+
{
49+
if (!in_array(self::PROTOCOL, stream_get_wrappers(), true)) {
50+
if (!stream_wrapper_register(self::PROTOCOL, self::class)) {
51+
return false;
52+
}
53+
}
54+
55+
// Generate cryptographically secure unique ID to prevent collisions
56+
$id = 'cb_' . bin2hex(random_bytes(16));
57+
self::$callbacks[$id] = $callback;
58+
59+
return fopen(self::PROTOCOL . "://{$id}", 'wb');
60+
}
61+
62+
/**
63+
* Clean up all registered callbacks (useful for testing).
64+
*
65+
* @internal
66+
*/
67+
public static function cleanup(): void
68+
{
69+
self::$callbacks = [];
70+
}
71+
72+
/**
73+
* Open the stream.
74+
*
75+
* @param string $path Stream path containing the callback ID
76+
* @param string $mode File mode (must contain 'w' for writing)
77+
* @param int $options Stream options (required by interface, unused)
78+
* @param string|null $opened_path Opened path reference (required by interface, unused)
79+
* @return bool True if stream opened successfully
80+
* @psalm-suppress UnusedParam $options and $opened_path are required by the stream wrapper interface
81+
*/
82+
public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool
83+
{
84+
if (!str_contains($mode, 'w')) {
85+
return false;
86+
}
87+
88+
$host = parse_url($path, PHP_URL_HOST);
89+
if ($host === false || $host === null) {
90+
return false;
91+
}
92+
93+
$this->id = $host;
94+
return isset(self::$callbacks[$this->id]);
95+
}
96+
97+
/**
98+
* Write data to the callback.
99+
*
100+
* @param string $data Data to write
101+
* @return int Number of bytes written
102+
* @throws RuntimeException If callback execution fails
103+
*/
104+
public function stream_write(string $data): int
105+
{
106+
if ($this->id === null) {
107+
trigger_error('Stream not properly initialized', E_USER_WARNING);
108+
return 0;
109+
}
110+
111+
$callback = self::$callbacks[$this->id] ?? null;
112+
if ($callback === null) {
113+
trigger_error('Callback not found for stream', E_USER_WARNING);
114+
return 0;
115+
}
116+
117+
try {
118+
$callback($data);
119+
} catch (Throwable $e) {
120+
throw new RuntimeException(
121+
'Callback function failed during stream write: ' . $e->getMessage(),
122+
0,
123+
$e
124+
);
125+
}
126+
127+
$length = strlen($data);
128+
$this->pos += $length;
129+
return $length;
130+
}
131+
132+
/**
133+
* Get current position in stream.
134+
*
135+
* @return int Current position
136+
*/
137+
public function stream_tell(): int
138+
{
139+
return $this->pos;
140+
}
141+
142+
/**
143+
* Check if stream has reached end of file.
144+
*
145+
* @return bool Always false for write-only streams
146+
*/
147+
public function stream_eof(): bool
148+
{
149+
return false;
150+
}
151+
152+
/**
153+
* Flush stream buffers.
154+
*
155+
* @return bool Always true (no buffering)
156+
*/
157+
public function stream_flush(): bool
158+
{
159+
return true;
160+
}
161+
162+
/**
163+
* Close the stream and clean up callback.
164+
*/
165+
public function stream_close(): void
166+
{
167+
if ($this->id !== null) {
168+
unset(self::$callbacks[$this->id]);
169+
$this->id = null;
170+
}
171+
}
172+
173+
/**
174+
* Get stream statistics.
175+
*
176+
* @return array<string, mixed> Stream statistics
177+
*/
178+
public function stream_stat(): array
179+
{
180+
return [
181+
'dev' => 0,
182+
'ino' => 0,
183+
'mode' => 0o100666, // Regular file, read/write permissions
184+
'nlink' => 1,
185+
'uid' => 0,
186+
'gid' => 0,
187+
'rdev' => 0,
188+
'size' => $this->pos,
189+
'atime' => time(),
190+
'mtime' => time(),
191+
'ctime' => time(),
192+
'blksize' => 4096,
193+
'blocks' => ceil($this->pos / 4096),
194+
];
195+
}
196+
197+
/**
198+
* Read data from stream (not supported - write-only stream).
199+
*
200+
* @param int $count Number of bytes to read (required by interface, unused)
201+
* @return string Always empty string
202+
* @psalm-suppress UnusedParam $count is required by the stream wrapper interface
203+
*/
204+
public function stream_read(int $count): string
205+
{
206+
trigger_error('Read operations not supported on callback streams', E_USER_WARNING);
207+
return '';
208+
}
209+
210+
/**
211+
* Seek to position in stream (not supported).
212+
*
213+
* @param int $offset Offset to seek to (required by interface, unused)
214+
* @param int $whence Seek mode (required by interface, unused)
215+
* @return bool Always false
216+
* @psalm-suppress UnusedParam $offset and $whence are required by the stream wrapper interface
217+
*/
218+
public function stream_seek(int $offset, int $whence = SEEK_SET): bool
219+
{
220+
trigger_error('Seek operations not supported on callback streams', E_USER_WARNING);
221+
return false;
222+
}
223+
224+
/**
225+
* Set options on stream (not supported).
226+
*
227+
* @param int $option Option to set (required by interface, unused)
228+
* @param int $arg1 First argument (required by interface, unused)
229+
* @param int $arg2 Second argument (required by interface, unused)
230+
* @return bool Always false
231+
* @psalm-suppress UnusedParam All parameters are required by the stream wrapper interface
232+
*/
233+
public function stream_set_option(int $option, int $arg1, int $arg2): bool
234+
{
235+
return false;
236+
}
237+
238+
/**
239+
* Truncate stream (not supported).
240+
*
241+
* @param int $new_size New size (required by interface, unused)
242+
* @return bool Always false
243+
* @psalm-suppress UnusedParam $new_size is required by the stream wrapper interface
244+
*/
245+
public function stream_truncate(int $new_size): bool
246+
{
247+
trigger_error('Truncate operations not supported on callback streams', E_USER_WARNING);
248+
return false;
249+
}
250+
}

0 commit comments

Comments
 (0)