Skip to content

Instantly share code, notes, and snippets.

@slightlyoff
Last active September 30, 2022 23:11
Show Gist options
  • Star 23 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save slightlyoff/18dc42ae00768c23fbc4c5097400adfb to your computer and use it in GitHub Desktop.
Save slightlyoff/18dc42ae00768c23fbc4c5097400adfb to your computer and use it in GitHub Desktop.
Delivering H/2 Push Payloads To Userland

Background

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.

Proposal

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);
});

Open questions

  • "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?

Alternatives Considered

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.

@rektide
Copy link

rektide commented Nov 7, 2017

@martinthomson I agree generally thatAnother addition to SSE would further entrench the use of what is a pretty bad mechanism.[link]. At the same time, the SSE proposal on the table is IMO a significantly stronger, more useful recommendation than anything else I've seen. It's ability to act broadly, cross-resourcefully, at scopes, is incredibly powerful & matches the mindspace I have as an application developer.

Like, say, bayeux, the SSE extension features the ability to subscribe to scopes. We see similar patterns in Etcd's wait-for-change API with it's ?recursive=true option- PUSH is different than this long-poll API form, but this resourceful view of pushed, subscribed content is a powerful one, and one SSE is far better mated to than the one-by-one Fetch proposal on the table.

The Fetch proposal on the table is by far the cleanest, most direct API I can see for implementing martin's WebPush Protocol. In this use case, a resource (or group of resources) is explicitly requested (Fetch'ed) with the expectation that the request will be held open indefinitely and PUSHes will arrive in reply to it. In this case, the Fetch proposal works great. Fetch is infinitely more appropriate a place to house this sort of addition makes semantic good-sense for cracking this use case & is very elegant.

But I find the ramifications deeply deeply unsettling and very limiting, such as for basic cases like trying to detect & render linked-data-island resources pushed in response a page (like index.html). If we want to start extending the DOM such that we can see replies pushed in response to documents and images and everything else, we can escape the pit-trap of writing something specific to a very limited piece of the platform (Fetch), but there's got to be more than specific-Fetch grade awareness here.

A long, long time ago, in a issue tracker far far away, there was Define a Fetch registry, which was merged and resolved. Today, in a different place, we still have a CustomElementRegistry. To look at this problem differently (not tied to Fetch or SSE) the idea of having some registry where content is registered (very close to the original Fetch registry purpose), but also extending EventTarget and dispatching ResourcePushEvents, feels to me like a more readily adaptable plan, using the best event-sourcing capabilities of the platform. With streamId's, it can readily implement WebPush protocol, while also allowing for a more general implementation of the "content was pushed I want to detect it and render it". Leaving this last need unfulfilled invites a tactic I have already desultorily fallen back to, polling the server for a list of resources that have been pushed at me (along with the cost of collecting and storing those list of pushed resources in the serving-farm). This is... radically unideal, and a limitation readily circumventable with painful imo unnecessary state tracking & coordination on the server's part. I don't want to see the web-platform create that kind of burden- it's not sympathetic to a use case people will make happen one way or another.

@LPardue
Copy link

LPardue commented Nov 28, 2017

I revisit this proposal with great interest, I gain more insight into the many facets each time.

I've spent some time recently looking at Server Push in HTTP/QUIC, it currently does away with notions of Stream ID and favours a Push ID. To that end, I'd encourage some care in assessing proposals such that any solution would naturally migrate to a future version of HTTP.

@jhampton
Copy link

@jakearchibald I found this thread and your article about browser support/implementation for HTTP/2 in the nick of time. I'm working on a project to implement a general-purpose fetch/XHR client that supports HTTP/2 push. I'm wondering if the needle has moved toward a standard since this thread began. I'll ping you on Twitter as well, and thank you very much for sharing your (hard-won) knowledge and thoughts with the community at large.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment