mkruisselbrink / navigator-connect

Apache License 2.0
25 stars 8 forks source link

Proposal for updated and renamed API to avoid near-collision with navigator.connection #39

Open bsittler opened 9 years ago

bsittler commented 9 years ago

In a service worker context, only deliver MessageEvent, ServiceConnectEvent and ServicePortCloseEvent to the active worker's navigator.services.

Attempts to call connect, match, or matchAll on navigator.services in a worker that is not active will cause promise rejection.

In a service worker context, the ServicePortCollection is persistent.

In a document context, only deliver MessageEvent and ServicePortCloseEvent to navigator.services.

In a document context the ServicePortCollection is ephemeral.

// navigator.services.connect is a reference to a ServicePortCollection
//
// ServicePortParams:
// optional string name
// optional any data
//
// ServicePortMatchParams:
// optional string name
// optional string targetUrl
//
// ServicePortCollection:
// connect(string uri, optional ServicePortParams) => ServicePort
// match(optional ServicePortMatchParams) => ServicePort
// matchAll(optional ServicePortMatchParams) => Array<ServicePort>
// addEventListener(...)
// removeEventListener(...)
// function onconnect and 'connect' event handler with ServiceConnectEvent
// function onmessage and 'message' event handler with MessageEvent
// function onclose and 'close' event handler with ServicePortCloseEvent
//
// Messageable:
// postMessage(any data, optional Array<Transferable>)
// close()
//
// MessagePort extends Messageable:
// (transferable)
//
// ServicePort extends Messageable:
// (nontransferable)
// string name
// any data
// string targetUrl
//
// ServiceConnectEvent:
// string origin
// acceptAsync(Promise<optional ServicePortParams>) => ServicePort
// ServicePort accept(optional ServicePortParams)
//
// MessageEvent:
// Messageable source
// any data
// string origin
// Array<Transferable> ports // transferable MessagePorts,
//                                            // not nontransferable ServicePorts
//
// ServicePortCloseEvent:
// ServicePort source

// In a client on https://client.origin (whether a worker or
// web content; the code is the same):

// second parameter is optional; default name is
// empty string ""; default data is undefined
navigator.services.connect('https://provider.tld/scope/hello_service',
       {name: 'hello_service', data: 123}).
then(function(port) {
  // port is entered in navigator.services now
  console.log(port.name); // "hello_service"
  console.log(port.data); // 123
  console.log(port.targetUrl); // "https://provider.tld/scope/hello_service"
  port.postMessage('The client greets the service.',
       [ /* optional: transferable objects */ ]);
  // port also has:
  // port.close() : removes from navigator.services and notifies far end
  // of disconnection; however, this end
  // will not receive any further events for the port
}, connectionErrHandler);

navigator.services.onmessage = function(ev) {
  // ev.source is the ServicePort object
  if (ev.source.name != 'hello_service') return;
  console.log(ev.origin); // "https://provider.tld" 
  console.log('The client got a message from the service ',
       ev.source.name, ' at ', ev.origin, ': ', ev.data, ev.ports);
};

navigator.services.onclose = function(ev) {
  // ev.source is the ServicePort object, and is no longer
  // present in navigator.services; no further
  // message or close events will be received for this port
  // ev.source.close() would be a no-op
  // ev.source.postMessage(...) would throw an exception
  console.log('The client was disconnected from the service ',
       ev.source.name, ' at ', ev.origin);
};

// ... and, at some later point:
navigator.services.matchAll({name: 'hello_service'}).then(function(ports) {
  ports.forEach(function(port) {
    port.close();
    // this ServicePort fires no further events and is removed from the collection
  });
});

// or... (picks one port with the given name)
navigator.services.match({name: 'hello_service'}).then(function(port) {
  port.close();
  // this ServicePort fires no further events and is removed from the collection
});

// In the service-providing service worker for https://provider.tld/scope/ :

navigator.services.onconnect = function(ev) {
  // ev is the client's connection attempt as seen by
  // the service with the following properties:
  // ev.targetUrl: first parameter in client's
  //     navigator.services.connect(...) call
  // ev.origin: client origin
  // The connection will be rejected if not already accepted
  // once ev is handled; use ev.acceptAsync(Promise) if you need to
  // do work first.
  if (ev.targetUrl === 'https://provider.tld/scope/hello_service') {
    // If accept was already called for this connection attempt
    // InvalidStateError will be thrown
    var port = ev.accept({name: 'hello_client', data: 456});
    // port is entered in navigator.services now
    console.log(port.name); // "hello_client"
    console.log(port.data); // 456
    console.log(port.targetUrl); // "https://provider.tld/scope/hello_service"
    connection.postMessage('The service greets the client.',
       [ /* optional: transferable objects */ ]);
  }
};

navigator.services.addEventListener('message', function(ev) {
  if (ev.source.name != 'hello_client') return;
  console.log(ev.origin); // "https://client.origin" 
  console.log('The client got a message from the service ',
       ev.source.name, ' at ', ev.origin, ': ', ev.data, ev.ports);
});

// ... and, at some later point:
navigator.services.matchAll({name: 'hello_client'}).then(function(ports) {
  ports.forEach(function(port) {
    port.close();
    // this ServicePort fires no further events and is removed from the collection
  });
});

// or... (picks one port with the given name)
navigator.services.match({name: 'hello_client'}).then(function(port) {
  port.close();
  // this ServicePort fires no further events and is removed from the collection
});

// or... (picks one port with the given targetUrl, then dispatches based on data)
navigator.services.match({targetUrl: 'https://provider.tld/scope/hello_service'}).then(
function(port) {
  if (port.data == 456) return;
  port.close();
  // that ServicePort fires no further events, and is removed from the collection
});
mkruisselbrink commented 9 years ago

I think there are a few individual bits of this API that could use some more discussion, probably in separate issues (the exact shape of the accept API, and maybe some other small things), but overall I think I'm quite happy with an API as is described here.

Rewriting the API in WebIDL I get something like this:

[NoInterfaceObject]
interface NavigatorServices {
  readonly attribute ServicePortCollection services;
};

Navigator implements NavigatorServices;
WorkerNavigator implements NavigatorServices;

[Exposed=(Window,Worker)]
interface ServicePortCollection : EventTarget {
  Promise<ServicePort> connect(DOMString url, optional ServicePortConnectOptions options);
  Promise<ServicePort> match(ServicePortMatchOptions options);
  Promise<sequence<ServicePort>> matchAll(optional ServicePortMatchOptions options);

  // Sends ServicePortConnectEvent, only sent to ServiceWorker contexts
  attribute EventHandler onconnect;
  // Sends MessageEvent or ServiceWorkerMessageEvent depending on context
  attribute EventHandler onmessage;
  // Sends ServicePortCloseEvent or ServiceWorkerServicePortCloseEvent depending on context
  attribute EventHandler onclose;
};

dictionary ServicePortConnectOptions {
  optional DOMString name;
  optional any data;
};

dictionary ServicePortMatchOptions {
  optional DOMString name;
  optional DOMString targetUrl;
};

[Exposed=(Window,Worker)]
interface Messageable {
  void postMessage(any message, optional sequence<Transferable> transfer);
  void close();
};

MessagePort implements Messageable;

[Exposed=(Window,Worker)]
interface ServicePort : Messageable {
  readonly attribute DOMString targetUrl;
  readonly attribute DOMString name;
  readonly attribute any data;
};

[Exposed=ServiceWorker]
interface ServicePortConnectEvent {
  readonly attribute DOMString origin;
  readonly attribute DOMString targetUrl;

  Promise<ServicePort> acceptLater(Promise<ServicePortConnectOptions> options);
  ServicePort accept(optional ServicePortConnectOptions options);
};

[Exposed=Window]
interface ServicePortCloseEvent {
  ServicePort source;
};

[Exposed=ServiceWorker]
interface ServiceWorkerServicePortCloseEvent : ExtendableEvent {
  ServicePort source;
};
bsittler commented 9 years ago

At least the Messageable part of this proposal may be of interest to those discussing postMessage changes in workers: https://groups.google.com/a/chromium.org/forum/#!topic/blink-dev/fUPpDcnOi64 What do you think? Should we try to propose Messageable as a simplification there?

mkruisselbrink commented 9 years ago

I'm not sure what you mean, or how Messageable is any kind of simplification there. The changes there are about making message events in service workers be ExtendableEvents so they can properly asynchronously handle message events. And of course for "our" onmessage event we should also use the appropriate MessageEvent/ServiceWorkerMessageEvent depending on if the event is delivered to a service worker or not. But Messageable itself doesn't change any API anywhere. It just makes the spec side of these message events somewhat simpler by only needing one interface as type for the source attribute.

bsittler commented 9 years ago

Sorry, should have clarified: that's exactly the change I was talking about (Messageable source.)

On Wed, Jun 10, 2015 at 1:17 PM, Marijn Kruisselbrink < notifications@github.com> wrote:

I'm not sure what you mean, or how Messageable is any kind of simplification there. The changes there are about making message events in service workers be ExtendableEvents so they can properly asynchronously handle message events. And of course for "our" onmessage event we should also use the appropriate MessageEvent/ServiceWorkerMessageEvent depending on if the event is delivered to a service worker or not. But Messageable itself doesn't change any API anywhere. It just makes the spec side of these message events somewhat simpler by only needing one interface as type for the source attribute.

— Reply to this email directly or view it on GitHub https://github.com/mkruisselbrink/navigator-connect/issues/39#issuecomment-110898670 .

mkruisselbrink commented 9 years ago

Ah, okay. Yes, of course for this API idea to work we'll need to update MessageEvent in the web messaging spec to allow a Messageable for its source (or add ServicePort, but that would be unfortunate). And then also update the service worker spec and its source attribute. Not sure who we'd need to talk to to actually get something like this in the web messaging spec (and how controversial that would be). At least the ServiceWorkerMessageEvent changes shouldn't be too hard to make.

mkruisselbrink commented 9 years ago

I started attempting to turn this into more spec language in the new-api branch, but still needs a lot of work obviously.

jungkees commented 9 years ago

for this API idea to work we'll need to update MessageEvent in the web messaging spec

The reason ServiceWorkerMessageEvent and ExtendableMessageEvent were defined is to extend the types that .source attribute can get hold of. (for ServiceWorker objects and Client objects for SW.) I also left notes in those sections in SW spec that when n.c comes in the picture, .source will be extended to embrace ports. (it was MessagePort at that time but can be ServicePort or Messageable if we settled with it.)

jungkees commented 9 years ago

In https://github.com/mkruisselbrink/navigator-connect/issues/39#issue-86746717,

navigator.services.onconnect = function(ev) {
  ..
  connection.postMessage('The service greets the client.',
       [ /* optional: transferable objects */ ]);
}

The connection above should be corrected to port returned from ev.accept(), right? So basically .accept() returns a ServicePort object for service end?

mkruisselbrink commented 9 years ago

The connection above should be corrected to port returned from ev.accept(), right? So basically .accept() returns a ServicePort object for service end?

Yes, that's right.

mkruisselbrink commented 9 years ago

The reason ServiceWorkerMessageEvent and ExtendableMessageEvent were defined is to extend the types that .source attribute can get hold of.

Oh yes, my mistake. I somehow mistakenly thought ServiceWorkerMessageEvent and ExtendableMessageEvent were the same, instead of what they actually are. That resolves that then.

annevk commented 9 years ago

So at Mozilla, there's quite a few folks who are still skeptical at the value of navigator.connect and what that can provide over a feature such as "foreignfetch" (invoke cross-origin service workers for cross-origin fetches, keeping everything tied to a HTTP context).

Was that considered? What were the reasons to go down a different path?

mkruisselbrink commented 9 years ago

"foreignfetch" or whatever you want to call it was definitely considered, and it certainly is something we want to push for as well, as described in our explainer document (most of the code samples in there are a bit outdated though, but the rest is still valid). There is also slightlyoff/ServiceWorker#684 for that part of the API, since it seemed that such an API more naturally would be part of the service worker spec instead of being a separate spec.

But we think that there is value in both a postMessage based API and a fetch based API. Many use cases can be easily solved with just a fetch based API, and a fetch based API degrades nicer when the target service worker isn't installed yet, but that doesn't mean that postMessage doesn't have value too. postMessage is in a way more statefull, making it a more natural choice when you want long running connections between client and service (also making it easier for the service to push data to the client).

So it's not that we're going down a different path, we're trying to go down both paths.

annevk commented 9 years ago

I guess we'd like to figure out if we don't need to go down the navigator.connect path, at least initially. "More stateful" doesn't seem particularly convincing and once we solve WebSocket for service workers the bidirectional case is solved too.