drupflare / stream-http
A PHP https:// stream wrapper backed by an injected fetch, for PHP-in-wasm builds that have no sockets.
Requires
- php: ^8.3
Requires (Dev)
- drupal/coder: ^9.0
- phpdocumentor/shim: ^3.10
- phpunit/php-code-coverage: ^11.0 || ^12.0
This package is auto-updated.
Last update: 2026-08-14 03:56:30 UTC
README
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.
π Table of Contents
- Install
- Quick Start
- The Fetch Contract
- Registering From the Host
- Out of Scope
- API
- Reading Response Metadata
- Testing
- Related Repositories
- License
π₯ Install
composer require drupflare/stream-http:0.*
π Quick Start
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/'); // hello
Nothing 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://.
π The Fetch Contract
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.
π₯ Registering From the Host
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.
π« Out of Scope
- 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.
π§ API
| 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.
π Reading Response Metadata
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.
π§ͺ Testing
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:check
The 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.
π Related Repositories
| 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 |
π License
MIT (c) Gregory Mitchell 2026. See LICENSE.