-
Notifications
You must be signed in to change notification settings - Fork 121
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add support for the new WebSocket notification protocol #1468
Conversation
05f00d6
to
73a0b94
Compare
+1. Unless already planned/possible, having SubscriptionStorage available through HTTP will support some use cases.
solid/notifications#111 as part of the idea to describe subscription requests and responses. We're also trying to figure out the compatibility (or to what degree) between #subscription-resource (the target of the #subscription-request) and Solid container: solid/notifications#36 . |
The current implementation could support this but there are several practical considerations to be resolved:
|
By "legacy solution", do you mean the implementation done in #1388? If that's the case, it would definitely make transition from legacy to new solution smoother. :) |
No, I meant the previous WebSocket notification solution that existed before this. Merging the result of #1388 with this is not feasible due to the differences. The plan is to look into supporting WebHooks with the architecture here after it is merged. |
Alright understood! Sounds good! 👌 |
f11fb8a
to
edada43
Compare
Did a small change so this also closes #440 since there was a handler in there that pretty much did what was asked there. |
edada43
to
486e032
Compare
486e032
to
e1f8cd6
Compare
e1f8cd6
to
27f4611
Compare
Is there an issue to address this (and do we have an idea of how to do it)? |
No. Makes more sense to create the issue if this is merged I think? But I don't see an easy solution for this. We would need a way to communicate across worker threads, similar to #726. |
Or completely decouple the WebSockets server, and have a (single) HTTP or socket line open from the main server to the WebSockets server. |
True, that is also an interesting idea. Then that part is singlethreaded though. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reviewed first 2 commits so far. Going well; just the ServerListener
thing was very confusing, but I proposed a solution.
* but with specific typings based on {@link GenericEventEmitter}. | ||
* Useful in case a class needs to extend {@link EventEmitter} and wants specific internal typings. | ||
*/ | ||
export function createGenericEventEmitterClass<T extends EventEmitter>(): (new() => T) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I find it a bit weird with the eslint-disable-next-line @typescript-eslint/naming-convention
; somehow I think this can be easier. Can't we just export a constant named EventEmitter<T>
here, which is actually the typed version of the Node EventEmitter
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
GenericEventEmitter<T>
is the typed version of EventEmitter
. The interface that is. The problem is classes that extend EventEmitter
, such as MonitoringStore
. if they still extend EventEmitter
you would not have the correct typings internally. So this.emit(...)
would not check for correct typings. Even if the class is defined as class MonitoringStore extends EventEmitter implements GenericEventEmitter<...>
. The only way to have the internal typings be correct is to extend a class that has correct typings (or rewrite all the functions internally).
So what we need is a class that is an implementation of EventEmitter, but actually says its interface is GenericEventEmitter<T>
. The above function generates such a class.
It might be possible to create a class with generics that can be extended immediately. All EventEmitter functions would have to be reimplemented though to cast them as TS would complain the output is not correct (although we know it is, which is why we can create this interface). But then that wouldn't allow us to combine multiple GenericEventEmitter
interfaces like we do for the activity emitter.
One of the problems is that there are less casting opportunities when extending and there is a clear difference between classes/implementations and interfaces/types/descriptions.
... or there is a different solution I didn't think of yet 😄 .
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
export class EventEmitter<T>
where the constructor returns new events.EventEmitter as any
? But yeah, you'd still need to type out the empty methods I presume.
And I see the problem of combining things indeed.
src/init/ServerInitializer.ts
Outdated
this.port = port; | ||
} | ||
|
||
public async handle(): Promise<void> { | ||
this.server = this.serverFactory.startServer(this.port); | ||
await this.serverListener.handleSafe(this.server); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mmm serverListener
is really a misnomer here; it is the object that attaches a server listener.
I would have called it an initializer, but then ServerInitializer
is already a thing.
However, it seems that ServerInitializer
is not doing a lot now. Could we just merge ServerInitializer
into the concrete server factory? But then we have the finalize
indeed…
Maybe ServerInitializer
becomes ServerManager
or ServerRunner
, and ServerListener
becomes ServerInitializer
, because it really only executes once.
That said, given that it only executes once… maybe the (current) ServerListener
(renamed to ServerInitializer
) should instead be a parameter to the server factory; it's their job to construct them in. Then the (current) ServerInitializer
indeed becomes simply ServerRunner
, whose job it is to start a server on a certain port of socket.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But ServerRunner
still extends Initializer
right? And ServerInitializer
would not extend Initializer
, so that might cause some confusion.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah right, Initializer
is a thing too. Even though it's just a simple async handler. Mmm… So do we really need it on ServerRunner
?
Or any other naming suggestions? ServerConfigurator
or so for the current listeners?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So do we really need it on
ServerRunner
?
We need an Initializer
that triggers everything related to creating a server and listening to the port, which is what ServerInitializer
currently is. An Initializer
is a handler that gets executed when the process starts so it's a fixed concept in CSS. I'm OK with ServerConfigurator
. Most names would be fine to me, would just not reuse the term Initializer
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ServerConfigurator
it is!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That said, given that it only executes once… maybe the (current)
ServerListener
(renamed toServerInitializer
) should instead be a parameter to the server factory; it's their job to construct them in.
Problem is that startServer
is a sync function while the ServerConfigurator
s are handlers and thus async. Although I don't really see a problem with making startServer
async as well.
27f4611
to
01dc8c3
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great work!
public async handle(input: WebSocket2021HandlerInput): Promise<void>; | ||
public async handle(input: NotificationEmitterInput | WebSocket2021HandlerInput): Promise<void> { | ||
if (this.isWebSocket2021HandlerInput(input)) { | ||
// Called as a WebSocket2021Handler: store the WebSocket |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mmm this is a bit spooky. It's because we only have one handle
method, right?
I think we should just break the handler
pattern here then; and if we need it, have a small inner object for subscriptions?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Darn I was hoping you were going to be "this is super cool!". Though about making a utility handler for this even 😉 .
But yes, it's because of the single handle
call. I initially had a solution with 3 classes, one of them being the small inner object that just stored the websockets (which is the thing both these handlers need). But it felt silly to make the configuration more complicated for these tiny classes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if it's possible in TypeScript to have a generic class, where handle
is part of the generic signature, such that the method names can be different 😅
But I really wouldn't overload handle
like this. And if we really must (I don't think we do), we should dispatch it to 2 different internal methods then; not have the code inline.
src/server/notifications/WebSocketSubscription2021/WebSocket2021Listener.ts
Outdated
Show resolved
Hide resolved
This allows us to have typed EventEmitters.
This allows us to decouple the WebSocket listening from the HTTP configs, making these features completely orthogonal.
This allows us to route after an Operation has been parsed
There is a conflict between notifications being sent out right before a test stops, and the file locker trying to clean up the locks folder. This only has an impact when shutting down the server so has no real impact, but should still be fixed eventually so we can easily write tests.
01dc8c3
to
81c9d88
Compare
There is going to be a lot of text in this comment as there is a lot to cover.
Adds support for the notification protocol. The editor's draft was also used as inspiration for certain parts. Specifically WebSocket subscriptions were added as that one has a published spec, but after this PR I might also look into adding webhooks due to the demand stated in the other PR.
Also adds basic support for a storage description resource as defined in the editor's draft of the 0.9.1 solid protocol spec as that is used for discovery of notification channels.
Alternative to #1388 as that one is based on an older version of the spec as discussed there.
As can be seen in the commits, there are first several utility commits before getting to the notification part.
Architecture
Markdown documentation was added explaining the architecture but will also summarize here.
There are 3 parts relevant for notifications:
I will cover the solutions for these separately below.
The main focus of the architecture was making sure the work for adding a new notification type is minimal and can reuse most of the existing components, but on the other hand still allows enough flexibility to support differences between the types. I feel like I mostly succeeded but there are still some things that feel like improvement might be possible so input is always appreciated.
Discovery
The editor's draft of the notification spec recommends using either the storage description resources for discovery, or the description resource of the resource you want to subscribe to. Seeing as how we handle description resources is still changing a lot, and we have to add a storage description resource for v0.9.1 in this PR. The latter could still be added somewhere in the future.
There are actually 2 discovery steps here: first one has to discover the storage description resource, and then that one has to be used to discover the notification channels.
Discovering the storage description
At the time of writing, the editor's draft of v0.9.1 states:
To this end, the
StorageDescriptionAdvertiser
(aMetadataWriter
) was created. For every request it will recursively look for parent containers until it finds one that is apim:Storage
. The identifier of that container is then used to generate the above header. The identifier is appended with a fixed suffix we use for the storage description. Currently this is.well-known/solid
. So if my pod ishttp://localhost:3000/joachimvh/
, a request tohttp://localhost:3000/joachimvh/card/profile#me
will have the storage description response header with valuehttp://localhost:3000/joachimvh/.well-known/solid
.Initially I thought about just using
http://localhost:3000/joachimvh/.meta
for the storage description resource, but that resource is not always accessible (due to read permissions) so can't be used for that.Generating the storage description
Due to the fixed suffix used for storage descriptions we can catch all requests targeting such a resource in a single handler. There we just have to make sure that the container, after removing the suffix, is a storage container.
To generate the output, there is the
StorageDescriber
interface which returns an array of quads. These then get merged and returned as output using content negotiation. This way we can combine multiple components that each want to put something in the description. Currently this is the required fixed triple (stating that the container is a storage), and the descriptions of the notification channels.Due to using quads, we currently don't guarantee that the JSON-LD output for notification discovery will have the expected format. But since there are still ongoing discussions about that, this seemed like a good solution for now. But it might still change in the future.
Specifically the
NotificationDescriber
is aStorageDescriber
that generates the necessary triples for a notification channel. It does so by having a fixed URL for every type. E.g., for websockets we have/.notifications/WebSocketSubscription2021
. This is the URL that clients need to discover to be able to subscribe.Subscribing
To handle subscriptions there is the
NotificationSubscriber
class. Should be wrapped in a router handler and a parsing handler so it receives Operations targeting a specific notification URL (such as the one mentioned above). It takes as input aSubscriptionType
, which is class that contains all the relevant information for a specific type.SubscriptionType
is one of those interfaces that feels like it could be improved to have a cleaner architecture.First the
SubscriptionType
is used to parse the input JSON body. Here theyup
library is used to have a base parser that can be extended if needed.After that the
SubscriptionType
returns which permissions are needed to fulfill this specific subscription. These are then used to run the standard authorization process.Finally the
SubscriptionType
is called to do whatever is required for a subscription of this type. Generally this will involve storing the subscription in aSubscriptionStorage
for later reuse. It also returns the response to return to the client.The
SubscriptionStorage
is the reason I created #1450.Emitting notifications
The
ListeningActivityHandler
is the new class that listens to the events emitted by theMonitoringStore
. It collects all relevant subscriptions from the storage mentioned above and for each of them calls itNotificationHandler
with the necessary information. This handler is responsible for making sure this info results in a notification being emitted.In practice we have a
ComposedNotificationHandler
that splits up the process into 3 separate steps and takes a separate handler for each of those steps:NotificationGenerator
converts the input info into a Notification object.NotificationSerializer
converts that object into a Representation.NotificationEmitter
takes the Representation and somehow emits it as required.One main reason for splitting up like this is reuse while allowing maximum flexibility. Several subscription types might all need to generate the same notification object, in a JSON-LD representation, without needing to add custom information. These would then all be able to reuse the same generator and serializer. On the other hand, there might be a subscription type that needs to add custom metadata to the representation before it can be emitted. In that case it is still possible to create a new serializer without having to change everything.
Another reason for this split is the
accept
feature from the notification spec. Clients can request to have their notifications sent to them in a different format from JSON-LD. This architecture allows us to have a reusableConvertingNotificationSerializer
in there which converts the resulting representation as necessary.Having a separate generator also allows us to cache the generated notification which is useful since a backend call is required to create it.
State
Another feature from the notification spec is the
state
feature. If a subscription has this field, the server should emit a notification to that client in case the resource has a different state than the one specified. One problem is that how to do this differs greatly between different subscription types (and caused a lot of headache to find a decent solution). In the case of WebSockets for example, we can't send this notification until the WebSocket is connected. In the case of WebHooks on the other hand, this could be sent immediately, but probably should be sent after we returned the subscription response. There is aBaseStateHandler
that can be used, but it is fully up to the implementations how and when this gets called. In the case of WebSockets the trigger is getting a matching connection.The comment was made offline that we could just always send out a notification and just buffer it if necessary, but there are some issues with that. For one, we don't know what the notification should look like until the WebSocket is connected: the state of the resource could change between the subscription and the connection. Also if we start buffering notifications a client might receive multiple at once when they connect.
External/internal components
I would have 1 version of the server that supports both the legacy as the new notification type. After that we can look into removing the legacy solution. There is also something to be said for having the new notification solution in a separate repository. A lot of changes are still happening there and it might need faster update cycles. On the other hand, it might cause confusion for users to not have everything in 1 place, and with differing version numbers. Something to discuss in a separate issue afterwards perhaps.
Permissions
Although the editor's draft of the notification spec doesn't have specifics anymore, the implementation here requires credentials to have Read permissions on a resource. One potential issue that I see is that if the permissions on a resource change, the subscription still stays valid. Having to keep track of permission changes would increase the complexity of this solution though. It is possible to configure the server so all subscriptions have a maximum expiration time, which is one way to make sure they at least don't stay permanent.
Multithreading
Due to storing the WebSockets in memory, this solution only works on singlethreaded servers. A consequence is that all the default configurations we expose will now throw an error if a user tries to start it with multiple worker threads.