Poppy.io / An Introduction
Poppy I/O is a JavaScript framework for sending data between web apps client-side through the user's web browser. It works by having one page (a client) launch another (a service) in a popup window (a "poppy"—hence "Poppy I/O"), connecting the two through a common cross-document messaging protocol.
This introduction is an interactive demo that lets you try it out and see what it looks like in code. It was written in May 2022 as part of the initial 0.1.0 release of Poppy I/O.
It's got three parts:
Note: This page uses some relatively modern JavaScript that won't work in older browsers, but Poppy I/O itself is tested to work in IE 11 (with a Promise polyfill). Also, this project isn't affiated with or endorsed by Flickr or Imgur or any other person, project, or organization that might be referenced in this page (aside from the author).
For this first part, we are going to select a photo from the Flickr Commons to bring into this page. The Flickr Commons is a collection of archival photographs with no known copyright restrictions, so we should be free to do whatever we want with the photo you pick when we get to Parts II and III.
This page you're currently on will function as the client, on the domain
. We'll be using
Poppy I/O to launch and transfer data to and from various service pages in popup
windows—all of which, importantly, will be on different domains, the first being
poppyio-commons-picker.glitch.me
.
To get started, click the button below to launch the Commons photo picker:
(After you pick an image, the origin of the image will be inserted here)
Hopefully that worked for you and you see the image you selected in the space above. Now let's take a look at the code. First a little setup:
ModalRequest
Purely for convenience we're using a script bundle that sets up the poppyio
global variable for this intro, specifically
The more modern thing to do would have been to import it as an ES module, where the equivalent would look like this:
And the more "production grade" thing would have been to use the poppyio
package in npm
and run it though a bundler.
The ModalRequest
class is the main thing we're interested in
as a client; we extract that into its own global out of poppyio
to make it look a little nicer.
The en
in the bundle and inject-en
import
have the effect of configuring a default version of two UI elements
with English-language strings (You can supply your own versions of
these and not use the default):
ModalRequest.prepareProxy()
we discuss in Note 2
iframe
s and ModalRequst.prepareProxy()
This helps work around some popup blockers, in particular Chrome on Android (at least as of May 2022).
The issue is that we don't open up the poppy popup window directly from the
client page's window. Instead we use an intermediate proxy iframe
that's sandboxed to prevent the poppy service from changing the client page URL
behind the user's back.
It turns out creating the proxy iframe
at the same
time we open the popup can somehow taint the iframe
causing the popup to be blocked. To work around this, we just have to create the
iframe
ahead of time, which is what ModalRequest.prepareProxy()
does. Note each time we open a poppy we create a new proxy (and destroy it on close),
this just sets up the first one.
The method takes an optional parameter, the parent element under which to create
the proxy (and modal overlay); by default it's added to the end of the body
.
With that out of the way, here's the button onclick
handler:
ModalRequest
constructorThe ModalRequest
class manages the "poppy" (the popup window hosting
the service page). While the request is open we try to make it act like
a modal dialog - making sure only one is open at a time, automatically
closing the popup when the user leaves the client page, and covering
up the client page with a modal overlay that restricts the user to only
cancelling the open request and closing the poppy.
(The rationale for all that is to make it harder for the user to lose track of the poppy and have extra popup windows they're not sure what to do with floating about...)
The ModalRequest
constructor takes an optional parameter
indicating the URL of the service page we want to load into the poppy.
If no service URL is specified, a "launcher" is presented
to allow the user to specify a service to use. As with the modal overlay,
a default implementation is included that you can replace.
Creating a ModalRequest
doesn't open the popup (or
have any side effects at all) - to open it use the open()
method, described in Note 3.
An Intent object specifies what a page on one side of a Poppy I/O exchange wants from the page on the other side, and how it will handle it. In this case, what the client wants from the service.
Every Intent is either accepting or offering
something, indicated by the presence of an accepting
or
offering
property on the Intent object (but not both),
with the value being a string or array of strings indicating the form
of the exchange we are able to accept or offer. The string can be
any value, so long as both sides agree on what they mean. Here in
Part I we use content-download
as the form (see Note 7
for more), in Part II we use edit-blob
, and in Part III we use
content-blob
.
The page opposite (the service) will present its own matching intent objects - accepting a form we are offering or offering a form we are accepting.
The having
property is optional and is an object indicating further details of
the exchange, such as the content-type of the data. Its meaning is specific to the form and is
not interpreted by the Poppy I/O library, but is simply passed through to the peer. This may
be counter-intuitive: if you are a client accepting an image/*
content-blob
for instance and the
service is offering an audio/mp3
content-blob
, then the Poppy I/O library won't prevent the exchange
even though it doesn't make sense. But consider:
having
value sent by a client and see whether or
not it's able to reasonably handle the request, and can inform the user if it isn't able to.
It's not used here, but an accepting intent may send a reply back to the offering
page using the replying
property. This may be a fixed value, but more usefully
it can be a function that returns the reply value (or a promise that resolves to the reply value).
The function will take a single PeerOffer
parameter, an object with 4 properties:
matched
- the matched PeerIntent
(See Note 5)data
- the "arrayified" offer data (See Note 4)ports
- an array of MessagePort
s included with the offer, for when a single reply/response
is not sufficient. The ports will be closed automatically when the replying()
completes
(when the returned promise is resolved/rejected if asynchronous)closing
- promise that resolves when the request is closing (and there's no point waiting
on any asynchronous operations anymore)
Instead of a replying()
function an accepting intent may alternatively specify a using()
function, which receives a second parameter - a postReply()
function used to send the reply
which additionally accepts a second transferList
array parameter allowing Transferable
objects to be sent with the reply.
ModalRequest.open()
To actually open the poppy and request the exchange call ModalRequest.open()
and pass it the intent object - or objects; you can also pass an array of intents to
provide more than one option, in which case one matching object will be selected.
The open()
method returns a Promise
that resolves
to the result of the exchange after it's complete. Note that the popup may
still be open at that point; if you want to wait for when the popup is closed
(to perform an animation for instance), await ModalRequest.closing
instead.
Because we're opening a popup window, ModalRequest.open()
needs to be called synchronously with respect to a user gesture
(like pushing a button) in order to avoid the popup being blocked.
See Note 4 for more about the result object.
The promise ModalRequest.open()
returns resolves to
a MatchResult
object with 1 or 2 properties. There's always a matched
property, which may be:
null
, if there wasn't a successful match (for instance the client was accepting a content-download
but the service could only offer a content-blob
)PeerIntent
object, if there was a successful match. See Note 7
for more about the PeerIntent
object.
In the second case, where a match was successful and matched
is not null
,
the object additionally has a value
property with the result of the exchange.
For a basic intent with no using()
callback, the result will be the data recieved
from the peer (the offered data on the accepting side, or reply data on the offering side), arrayified.
"Arrayification" is just making it so that the data received is always accessed through an array, the process being:
null
or undefined
, then the
value is an empty array.
The array has an additional raw
property allowing you to get the original
value recieved, pre-arrayification.
For "Posting" intents (those which have a using()
function that calls postOffer()
or postReply()
to send data to the peer) the result value
will be whatever the using()
function returns.
PeerIntent
objects
As mentioned in Note 4, if the
exchange was successful, the matched
property of the
result will be a PeerIntent
. A PeerIntent
represents the matched intent on the peer web page.
A PeerIntent
object has 4 properties:
origin
- a string, the web origin of the pageside
- either the string accepting
or offering
, which
will be the opposite of our side.form
- a string, the form of the exchange offered or accepted. In contrast to
a submitted intent which may have an array of forms, only one matching form will be in the peer intent.having
- an object, equivalent to the having
object in the submitted
intent.
As mentioned in Note 5, with the
simple accepting intent we have, the result.value
will
be an array (with an additional raw
property).
Although the exchange was successful, so we should expect at least one value in the array, that isn't guaranteed. Poppy I/O doesn't perform data validation for you. It's just a generic way of connecting pages together.
The if
condition validates the result is usable:
!result.value[0]
checks that:undefined
)null
or undefined
- both are "falsy" valuesresult.value[0].download !== 'string'
to make
sure the URL is a string. (The URL may not be valid, but rather than validate that here we just try to load
it and let it fail).
See Note 7 for more about the download
property
in a content-download
offer.
In content-download
form, data is passed by a download URL (as
opposed to a share URL or hotlink URL). It's expected to be usable from client-side
JavaScript directly, so it must be served using HTTPS with appropriate CORS headers.
But it's not expected to be long lived and can expire shortly (although that is not
the case here.)
To allow for additional metadata to be included with the URL, it's not sent directly
but is wrapped in an object with the URL value being in the download
property,
for instance:
{
download: "https://example.com/hello-world.txt",
title: "A greeting"
}
In Part I we had a one-way transfer from the service to the client, now we'll do a two-way transfer - from the client to the service and then from the service back to the client - to take the image you selected and transform it into something else.
For this I took two existing projects I thought were pretty cool and then hacked them up quickly to make them function as Poppy I/O services. If anything is weird or broken, definitely blame me and not the original authors!
First we have primitive.js, by Ondřej Žára and based on the original Primitive in Go by Michael Fogleman. What this does is take an input image:
And then try to re-produce it by building an image up from primitive geometric shapes, giving it an abstract look. This for instance is with 145 triangles:
And then we have jpg-glitch by Georg Fischer. This is kind of the opposite of Primitive in that instead of building an image up, it takes an input image:
And then tears it down, rendering it as a JPEG and then corrupting it slightly (or not-so-slightly) to make it glitch:
The actual effect seems to depend on the JPEG rendering of the browser used to produce the glitch, subjectively I think it's a lot less interesting looking when run on Safari than on other browsers (I suppose on iOS it all looks the same), but maybe you'll think different.
If you're not sure which one to try I suggest jpg-glitch
first since
it's faster.
For reference, here's the original image again:
(After you pick an image, the origin of the image will be inserted here)
And here it is after you run one of the 2 options above:
(After you edit the image, the origin of the editor will be inserted here)
And now for the code:
edit-blob
The simple way to offer an object is to use sending
, the value of which can
be:
The function gets a single parameter, a PeerAcceptor
with two properties:
matched
, PeerIntent
with the details of the matching intent (described in part one),
and, and closing
, a Promise
that resolves when the request is closing.
Because opening a ModalRequest
involves opening a popup window,
you can't wait for an asynchronous operation in between the user gesture requesting
a poppy be opened and the opening of a poppy, instead you should use a promise or asynchronous
function returning a promise as the sending
value.
In this case the form we're using is edit-blob
, where we send a blob we want the
service to transform, sending us the transformed blob in the reply. As with content-download
the blobs (both ways) are wrapped in an envelope object with the Blob value in the blob
property, for instance:
{
blob: new Blob(["Hello, World!"], {type:"text/plain"},
title: "A Greeting"
}
We expect a reply with the same shape (but of course need to be prepared for the case where the reply is not)
Both services support the same interface (edit-blob
form) with the only difference
being the service URL.
This makes it easier to integrate new services and also potentially allows users to specify entirely new services to use themself, demonsrtated in Part III.
In Part II we sent an image out to a service to get another back in return. So naturally you can also use Poppy I/O to send an image out and then just like... not get something back.
Let's do that and publish your image to Imgur for the world to see. But instead of specifying the service URL to use in the code, let's let the user (i.e. you) pick the service they want to use.
There are a number ways that could work—a very simple option would be to just have the user enter the service page URL. More user-friendly might be a big directory or app store kind of thing with a whole bunch of services to choose from (all zero of them).
What's actually implemented is a simpler (for the user) variant of the former, where instead of entering a full service page URL, you just enter a domain name and the code will do a little extra work to figure out exactly what the service page URL is.
For instance, if a user enters i3r.poppy.io
, it will:
https://i3r.poppy.io/.well-known/host-meta.json
/.well-known/host-meta.json
must be served with appropriate CORS headers for access from any originhost-meta.json
to be a JRD document.
Within it, we find the first link with a rel
of https://purl.org/pio/a/ModalService
{"links": [
{"rel": "https://purl.org/pio/a/ModalService",
"href": "/service.html"}
]}
href
can be an absolute URL or an absolute path
relative to the location of the host-meta.json
file.
href
. Otherwise, display an error message.A nice property of this is that if you enter a domain for a service that doesn't exist then we can detect that and stop you rather then sending you off and letting you fend for yourself. A not so nice property is that you might make a typo and enter a domain for a service that does exist... but only to trade your data for Satoshis.
So be careful.
So right now i3r.poppy.io
is the only service currently operational. But
you can run it yourself, on your own domain, making it kinda like another service.
All you need is an Imgur Client ID, and it's pretty simple to deploy it to Heroku
for free, or run it locally (using something like ngrok to take care of SSL).
If you want to give it a go, check out poppyio-i3r on Github.
When prompted for the domain, enter i3r.poppy.io
or that of your own running instance.
After you finish, the link to your image on Imgur and a link to delete it if you're so inclined will be inserted
here:
And here's the code:
ModalRequest
without specifying a service
We don't specify a service to use, which causes the ModalRequest
to
open a launcher instead. In this case, we're using a default implementation
included with the library which simply shows an input to allow the user to specify
a service domain to use. (The process used to find the service page URL based on the
domain is outlined above).
The launcher is configurable by specifying the launcher
property
of ModalRequest
, and can be configured globally by setting
ModalRequest.prototype.launcher
, as the inject-en.js
module does when we import it from setup.js
(in Part I).
I don't have a firm idea of what to send back from a one-way object offer, but one thing that seems useful is a list of links to where the data now lives.
Remember Poppy I/O doesn't do any data validation and there's nothing
stopping the peer page from sending a javascript:
link for instance to perform a cross-site scripting attack.
So we make sure it's at least an https://
link before inserting
it into the page.
Basically treat anything you receive from Poppy I/O as you would any untrusted data from an external service.
Well! Hopefully this introduction gave you a good sense of what Poppy I/O is all about. There's a lot more we could go over, like more "advanced" usage beyond sending simple objects back and forth, the idea of connecting clients together peer-to-peer, the details of the protocol itself, integration with browser extensions, some discussion of safety and security...
Until I get around to that you can try filling in some of that on your own by looking through the code of Poppy I/O itself which you can find here on Github. Maybe also check out:
Being at 0.1.0 neither the API nor underlying protocol are set in stone and I am very happy to receive your suggestions for breaking improvements.
Finally I thought I should mention what you might call "prior art", although I don't think that's entirely accurate to say since I've been playing around with this idea for an embarassingly long time—the oldest related artifact I can find online is this page which has an HTTP Last-Modified date of Wed, 06 Oct 2010 02:40:08 GMT (I think it was me experimenting with UI code). So, "parallel art" maybe?
So yeah, I guess we'll see where that goes. If you want to keep up to date in case there's anything new with Poppy I/O consider following my currently very empty Twitter, @paulgaspardo, and if you have any issues or suggestions or questions feels free to open up an issue or discussion on the main Poppy I/O repository on Github.
Thanks :)
Paul Gaspardo
May 2022