One of the biggest missed opportunities thus far with HTTP/2 ("H/2") is that we are not yet able to sunset WebSockets in favor of H/2. Web Sockets and H/2 both support multiplexing messages bi-directionally and can send both textual and binary data.
Server Sent Events ("SSE"), by contrast, are not bi-directional (they're a "server-push-only" channel) and binary data cannot be sent easily. They are, however, very simple to implement. Adding to the menagerie of options, RTCPeerConnection
can also be used to signal data to applications in a low-latency (but potentially lossy) way.
Because H/2 does not support the handshake (upgrade) that WebSockets use to negotiate a connection (abandoned draft approach to repairing here), web developers are faced with a complex set of choices regarding how to handle low-latency data updates for their apps.
Web apps may benefit from a WebSocket-like API for receiving data updates without a custom protocol (e.g., Web Sockets or SSE) and associated infrastructure. An API for H/2 push receipts seems like a natural solution to this quandry, but H/2 has created a non-origin sharing concept for pushed responses. The upshot is that H/2's behavior needs to be futher constrained to only consider single origins (or URLs) so that an application is not incidentially confused by pushes into a shared H/2 cache by an app for which the server is also authoritative but which the application does not wish to trust.
The Server Sent Events API, perhaps, provides a way forward. What if, instead of only connecting to SSE-protocol supporting endpoints, a call to new EventSource(...)
could also subscribe to pushed updates to individual URLs (or groups of URLs) and allow notifiation to the application whenever an H/2 PUSH_PROMISE
resolves?
A minor extension to SSE might allow use of H/2 push as a substrate for delivering messages, with a slight modification of the event payload to accomodate providing Response
objects as values:
// If an H/2 channel is open, any PUSH_PROMISE for the resource at
// `/endpoint` will trigger a "resourcepush" event
let es = new EventSource("/endpoint", {
withCredentials: true
});
es.addEventListener("resourcepush", (e) => {
console.log(e.data instanceof Response);
});
Opting in to globbing for all push promises below a particular scope might look like:
// Any resource pushed to "/endpoint" or anything that matches that
// as a prefix will deliver to this handler
let es = new EventSource("/endpoint", {
withCredentials: true,
matchScope: true
});
es.addEventListener("resourcepush", (e) => {
console.log(e.data.url);
});
// If multiple handlers are installed, it's longest-prefix-wins, ala
// Service Workers:
let es = new EventSource("/endpoint/channel", {
withCredentials: true,
matchScope: true
});
// If a push is sent to `/endpoint/blargity`, will be handled by previous source
// If a push is sent to `/endpoint/channel`, `/endpoint/channel1`, or
// `/endpoint/channel2`, it will be delivered below:
es.addEventListener("resourcepush", (e) => {
console.log("channel-pushed resource:", e.data.url);
});
"resourcepush"
is a terrible name.- should updates to previously pushed resources be opt-in instead of transparent?
- do we need a version of this API that allows delivery to Service Workers?
- how much of a problem is the H/2 vs. Origin Model split in practice? Do we actually know?
- via Ryan Sleevi, to what extent should we allow subscriptions to any origin?
A notable downside of the proposed design is that it might "miss" same-origin payloads sent earlier in the page's lifecycle over the channel. One could imagine a version of the proposal that adds a flag to enable subscription-time delivery of these resources so long as they're still in the push cache. Putting this feature as another optional flag feels clunky, though.
One way to make it less clunky would be to describe the API in terms of an Observer pattern (e.g. Intersection Observer). The above example might then become:
let options = {
path: "/endpoint",
withCredentials: true,
matchScope: true
};
let callback = (records, observer) => {
records.forEach((r) => {
console.log(r instanceof Response); // === true
};
};
let resoureObserver = new ResourceObserver(callback, options);
This feels somewhat better as one of the key distinguishers of Observer-pattern APIs is their ability to scope interest in a specific subset of all events internal to the system. We also dont need to cancel delivery, so phraising it as an Event might not be great. It is, however, much more new API surface area.
Another considered alternative might instead put registration for listening inside a Service Worker's installation or activation phase; e.g.:
self.addEventListener("install", (e) => {
e.registerResourceListener("/endpoint", {
withCredentials: true,
matchScope: true
});
});
self.addEventListener("resourcePush", (e) => {
console.log("channel-pushed resource:", e.data.url);
});
This is interesting because it would allow us to avoid delivering these events to all windows and tabs which are open, and doesn't upset our design goal for Service Workers to be shut down when not in use. The ergonomics suffer somewhat: what about pages that don't have a SW (e.g., first-load)? Does handling resource pushes then gate on SW install time (which can take a long while)? Also, Response
objects can't be sent postMessage
to clients as they aren't structured-clonable, meaning the SW would need to signal to a page that an update is available and then, perhaps, transfer the data by extracting it from the Response
body to send via postMessage
or transfer using the Cache API.
For flushing H/2 cached resources I think
EventSource
API is enough and I don't see much point in using Observer API.When SSE is created, it could immediately receive events for all cached pushes. If you want that to be explicit, you can introduce a new
readyState
for this or add a flag to the events to mark them as cached. Normal SSE receives a backlog of previous messages with last-event-id, so getting a backlog of previous pushes is not too different conceptually.Alternatively, is it fair to say that H/2 push is a feature of the cache/inseparable from a cache? Maybe it should be an event on
window.Cache
?