An
https://stream wrapper for PHP builds that have no sockets
file_get_contents('https://...') works again in a PHP-in-wasm runtime, because you hand the
package the fetch. No sockets, no cURL, no OpenSSL transport, no framework, and no dependency on
the host it was extracted from.
- Install
- Quick Start
- The Fetch Contract
- Registering From the Host
- Out of Scope
- API
- Reading Response Metadata
- Testing
- Related Repositories
- License
composer require drupflare/stream-http:0.*use Drupflare\StreamHttp\HttpsStreamWrapper;
// whatever your runtime can actually do; this one is a Cloudflare Worker host call
HttpsStreamWrapper::register(static function (array $request): array {
// perform the request here and return the reply shape below
return ['ok' => true, 'status' => 200, 'headers' => [], 'body' => 'hello'];
});
echo file_get_contents('https://example.com/'); // helloNothing else is needed. fopen(), fread(), fseek(), feof(), filesize(), file_exists()
and stream_get_contents() all work from that point on, for both http:// and https://.
One callable and two array shapes are the entire integration surface.
The request the wrapper builds:
| Key | Type | Notes |
|---|---|---|
url |
string |
verbatim, exactly as the caller wrote it |
method |
string |
upper-cased; GET unless a stream context says otherwise |
headers |
array |
name to value, from the context's header string or list |
body |
string|null |
the context's content, or null |
redirect |
string |
follow or manual, from the context's follow_location |
The reply it accepts:
| Key | Type | Notes |
|---|---|---|
ok |
bool |
anything other than true is a refusal |
body |
string |
required when ok β send '' for a bodiless response |
status |
int |
optional; cast, and reported through responseMeta() |
headers |
array |
optional; non-string values are dropped rather than cast |
error |
string |
the named reason, when ok is not true |
body is required, not defaulted to ''. A default would make a transport that omitted the field
look like a genuinely empty 204.
A stream wrapper has to exist before any code that uses one runs. A service container is built
too late for that, so a service provider or a module hook is the wrong place. In the project this
was extracted from, registration happens from the JavaScript host, in
worker/src/drupal/site-php.ts, around the HttpsStreamWrapper::register() call in the boot
script β before Drupal's kernel exists.
- It does not stream. The whole body is fetched on open and served from memory, because PHP's
stream_read()is synchronous and a real streaming read would have to suspend the interpreter mid-call. A build without suspension can only fetch-then-read. - It does not write. A write would be a POST with no way to signal completion through the
stream API, so a non-
rmode is refused by name andstream_write()returns 0. url_stat()spends no request. PHP stats speculatively, and aHEADper stat would cost a request each time, sofile_exists()answers "yes, unknown size" andfilesize()on a URL nobody opened reads 0. That trade-off is pinned by an assertion rather than left to be rediscovered.
| Member | What it does |
|---|---|
register(callable $fetch, ?array) |
sets the fetch and takes over the schemes; returns what it claimed |
unregister(?array $schemes) |
restores PHP's own wrappers and forgets the fetch |
setFetch(callable $fetch) |
sets the process-wide fetch without touching the registry |
fetcher() |
the process-wide fetch, or null |
lastError() |
the last named refusal, because a stream call only returns false |
new HttpsStreamWrapper(?callable) |
an instance with its own fetch, which wins over the process-wide one |
responseMeta() |
status and headers of the response this instance is serving |
SCHEMES |
['http', 'https'], the registrar's default |
Why both a constructor and a static registrar. PHP constructs a registered wrapper with no
arguments β stream_wrapper_register() takes the class name, not an instance β so a
constructor-only design cannot work for the registered path. The static registrar is the production
route; the constructor parameter is the escape hatch that makes the class drivable directly, which
is how the suite exercises it without touching the wrapper registry at all.
PHP populates $http_response_header only for its own http wrapper, and a userland wrapper cannot
set it. What PHP does give you is the wrapper instance itself:
use Drupflare\StreamHttp\HttpsStreamWrapper;
$fh = fopen('https://example.com/', 'r');
$meta = stream_get_meta_data($fh);
// wrapper_data IS the HttpsStreamWrapper instance for a userland wrapper
['status' => $status, 'headers' => $headers] = $meta['wrapper_data']->responseMeta();Note
This method is not called stream_metadata. That name belongs to the streamWrapper
prototype, where PHP invokes it as stream_metadata(string $path, int $option, mixed $value) for
touch(), chmod() and chown(). A 0-argument method under that name is an
ArgumentCountError waiting for the first caller who touches an http:// path.
Every byte moves through the injected callable, so anything the wrapper cannot do refuses with a
reason rather than returning a plausible empty result. A silent '' or false out of a stream
function cannot be told apart from a real empty response.
PHP discards the warning text, so the reason is also readable afterwards:
use Drupflare\StreamHttp\HttpsStreamWrapper;
$fh = @fopen('https://example.com/', 'w');
if ($fh === false) {
// "β¦HttpsStreamWrapper is read-only, so mode "w" is refused"
echo HttpsStreamWrapper::lastError();
}Every one of these is refused by name and covered by the suite:
| Situation | Named reason contains |
|---|---|
a non-read fopen() mode |
is read-only, so mode "w" is refused |
| no fetch was ever set | ::register($fetch) first |
| the fetch threw | the exception class and its message |
| the fetch returned a non-array | where an array was expected |
ok is false or absent |
the reply's error, or no reason given |
ok with a missing or non-string body |
send an empty string for no body |
| a seek outside the body | outside a N-byte body |
| a write through a read handle | read-only |
An explicitly empty body ('body' => '') opens normally, which is the control that keeps the six
malformed-reply assertions from being vacuous.
composer install
composer test # php -l over the tree, then 91 assertions
composer coverage # the same suite under xdebug or pcov, writing Clover to coverage/
./vendor/bin/phpcs
bun install && bun run prettier:checkThe suite injects a recording fetch, so every request the wrapper builds is inspected and every malformed reply is scripted. No socket, no server, no wasm runtime.
A stream wrapper is resolved by method name at runtime: rename stream_read and PHP never calls
it, with no error anywhere. The suite therefore drives the class through fopen(), fread() and
fseek() wherever it can, rather than calling methods directly.
| Repository | What it is |
|---|---|
drupflare/worker |
the consumer: Drupal 11 on Cloudflare Workers |
drupflare/drupflare |
the Drupal module this was extracted from; wires Host::call |
drupflare/cartridge |
the host side: running a blocking interpreter in a DO |
drupflare/phasm |
builds the interpreter, including the JSPI flags above |
MIT (c) Gregory Mitchell 2026. See LICENSE.