feat: Add support for WebSocketSubscription2021

This commit is contained in:
Joachim Van Herwegen 2022-09-30 09:32:10 +02:00
parent cbc07c6ef3
commit b1f7a6a8b1
54 changed files with 1173 additions and 56 deletions

View File

@ -23,6 +23,7 @@
"Readonly",
"RegExp",
"Server",
"SetMultiMap",
"Shorthand",
"SubscriptionType",
"Template",

View File

@ -6,6 +6,8 @@
- The server can be configured to use [ACP](https://solidproject.org/TR/acp) instead of WebACL.
`config/file-acp.json` is an example of a configuration that uses this authorization scheme instead.
- Support for the new [WebSocket Notification protocol](https://solidproject.org/TR/websocket-subscription-2021)
was added.
### Data migration
@ -22,6 +24,10 @@ The following changes pertain to the imports in the default configs:
- All default configurations which had setup disabled have been updated to also disable registration.
This is done to prevent configurations with accidental nested storage containers.
- All references to WebSockets have been removed from the `http/middleware` and `http/server-factory` imports.
- A new `http/notifications` set of import options have been added
to determine which notification specification a CSS instance should use.
All default configurations have been updated to use `http/notifications/websockets.json`.
The following changes are relevant for v5 custom configs that replaced certain features.
@ -30,12 +36,22 @@ The following changes are relevant for v5 custom configs that replaced certain f
- `/app/main/default.json` now imports the above config file.
- All files configuring template engines.
- Several minor changes due to support ACP.
- `ldp/authorization/*`
- `/ldp/authorization/*`
- Resource generation was changed to there is 1 reusable resource generator.
- `init/initializers/*`
- `setup/handlers/setup.json`
- `identity/access/initializers/*`
- `identity/pod/*`
- `/init/initializers/*`
- `/setup/handlers/setup.json`
- `/identity/access/initializers/*`
- `/identity/pod/*`
- Creating an HTTP(S) server is now separate from attaching a handler to it.
- `/http/server-factory/*`
- The WebSocket middleware was moved to the relevant WebSocket configuration.
- `/http/middleware/*`
- Storage description support was added.
- `/http/handler/*`
- `/ldp/metadata-writer/*`
- Notification support was added.
- `/http/handler/*`
- `/notifications/*`
### Interface changes
@ -50,6 +66,7 @@ These changes are relevant if you wrote custom modules for the server that depen
- `TemplatedResourcesGenerator` has been renamed to `BaseResourcesGenerator` and has a different interface now.
- `CredentialSet` was replaced by a single `Credentials` interface.
This impacts all authentication and authorization related classes.
- `HttpServerFactory.startServer` function was renamed to `createServer` and is no longer expected to start the server.
## v5.1.0

View File

@ -7,7 +7,7 @@
"css:config/app/variables/default.json",
"css:config/http/handler/default.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/legacy-websockets.json",
"css:config/http/notifications/websockets.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/access/public.json",

View File

@ -7,7 +7,7 @@
"css:config/app/variables/default.json",
"css:config/http/handler/default.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/legacy-websockets.json",
"css:config/http/notifications/websockets.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/access/public.json",

View File

@ -7,6 +7,7 @@
"css:config/app/variables/default.json",
"css:config/http/handler/default.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/websockets.json",
"css:config/http/static/default.json",
"css:config/identity/access/public.json",

View File

@ -7,6 +7,7 @@
"css:config/app/variables/default.json",
"css:config/http/handler/default.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/websockets.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/access/public.json",

View File

@ -7,7 +7,7 @@
"css:config/app/variables/default.json",
"css:config/http/handler/default.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/legacy-websockets.json",
"css:config/http/notifications/websockets.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/access/public.json",

View File

@ -7,7 +7,7 @@
"css:config/app/variables/default.json",
"css:config/http/handler/default.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/legacy-websockets.json",
"css:config/http/notifications/websockets.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/access/public.json",

View File

@ -14,19 +14,26 @@ Sets up all the handlers a request will potentially pass through.
A set of handlers that will always be run on all requests to add some metadata
and then pass the request along.
* *no-websockets*: The default setup but without the websocket-related metadata.
* *websockets*: The default setup with several handlers.
* *default*: The default setup with several handlers.
## Notifications
Determines how notifications should be sent out from the server when resources change.
* *disabled*: No notifications are sent out.
* *legacy-websocket*: Follows the legacy Solid WebSocket
[specification](https://github.com/solid/solid-spec/blob/master/api-websockets.md).
* *websockets*: Follows the WebSocketSubscription2021
[specification](https://solidproject.org/TR/websocket-subscription-2021).
## Server-Factory
The factory used to create the actual server object.
* *no-websockets*: Only HTTP.
* *websockets*: HTTP and websockets.
* *https-no-websockets*: Only HTTPS. Adds 2 new CLI params to set the key/cert paths.
* *https-websockets*: HTTPS and websockets. Adds 2 new CLI params to set the key/cert paths.
* *https-example*: An example configuration to use HTTPS directly at the server (instead of at a reverse proxy)
by adding the key/cert paths to the config itself.
* *http*: A HTTP server.
* *https*: A HTTPS server.
* *https-no-cli-example*: An example of how to set up an HTTPS server
by defining the key/cert paths directly in the config itself.
## Static

View File

@ -2,8 +2,8 @@
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld",
"@graph": [
{
"comment": "The suffix appended to a storage container to find its description resource.",
"@id": "urn:solid-server:default:variable:storageDescriptionSuffix",
"comment": "The relative path appended to a storage container URL to find its description resource.",
"@id": "urn:solid-server:default:variable:storageDescriptionPath",
"valueRaw": ".well-known/solid"
},
{
@ -22,7 +22,7 @@
"operationHandler": {
"@type": "StorageDescriptionHandler",
"store": { "@id": "urn:solid-server:default:ResourceStore" },
"suffix": { "@id": "urn:solid-server:default:variable:storageDescriptionSuffix" },
"path": { "@id": "urn:solid-server:default:variable:storageDescriptionPath" },
"converter": { "@id": "urn:solid-server:default:RepresentationConverter" },
"describer": { "@id": "urn:solid-server:default:StorageDescriber" }
}

View File

@ -14,6 +14,6 @@
"@type": "RelativePathInteractionRoute",
"base": { "@id": "urn:solid-server:default:variable:baseUrl" },
"relativePath": "/.notifications/"
},
}
]
}

View File

@ -0,0 +1,18 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld",
"import": [
"css:config/http/notifications/base/description.json",
"css:config/http/notifications/base/handler.json",
"css:config/http/notifications/base/listener.json",
"css:config/http/notifications/base/storage.json",
"css:config/http/notifications/websockets/description.json",
"css:config/http/notifications/websockets/handler.json",
"css:config/http/notifications/websockets/http.json",
"css:config/http/notifications/websockets/subscription.json"
],
"@graph": [
{
"comment": "All the relevant components are made in the specific imports seen above."
}
]
}

View File

@ -0,0 +1,18 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld",
"@graph": [
{
"@id": "urn:solid-server:default:StorageDescriber",
"@type": "ArrayUnionHandler",
"handlers": [
{
"comment": "Handles the storage description triples used for discovery of a WebSocketSubscription2021 endpoint.",
"@type": "NotificationDescriber",
"route": { "@id": "urn:solid-server:default:WebSocket2021Route" },
"relative": "#websocketNotification",
"type": "http://www.w3.org/ns/solid/notifications#WebSocketSubscription2021"
}
]
}
]
}

View File

@ -0,0 +1,31 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld",
"@graph": [
{
"comment": "Handles the generation and serialization of notifications for WebSocketSubscription2021.",
"@id": "urn:solid-server:default:WebSocket2021NotificationHandler",
"@type": "TypedNotificationHandler",
"type": "WebSocketSubscription2021",
"source": {
"@type": "ComposedNotificationHandler",
"generator": { "@id": "urn:solid-server:default:BaseNotificationGenerator" },
"serializer": { "@id": "urn:solid-server:default:BaseNotificationSerializer" },
"emitter": { "@id": "urn:solid-server:default:WebSocket2021Emitter" }
}
},
{
"comment": "Emits serialized notifications through WebSockets.",
"@id": "urn:solid-server:default:WebSocket2021Emitter",
"@type": "WebSocket2021Emitter",
"socketMap": { "@id": "urn:solid-server:default:WebSocketMap" }
},
{
"@id": "urn:solid-server:default:NotificationHandler",
"@type": "WaterfallHandler",
"handlers": [
{ "@id": "urn:solid-server:default:WebSocket2021NotificationHandler" }
]
}
]
}

View File

@ -0,0 +1,46 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld",
"@graph": [
{
"comment": "Catches newly opened WebSockets and verifies if they belong to a subscription.",
"@id": "urn:solid-server:default:WebSocket2021Listener",
"@type": "WebSocket2021Listener",
"storage": { "@id": "urn:solid-server:default:SubscriptionStorage" },
"route": { "@id": "urn:solid-server:default:WebSocket2021Route" },
"handler": {
"@type": "SequenceHandler",
"handlers": [
{ "@id": "urn:solid-server:default:WebSocket2021Storer" },
{ "@id": "urn:solid-server:default:WebSocket2021StateHandler" }
]
}
},
{
"comment": "Opened WebSockets will be stored in this Map.",
"@id": "urn:solid-server:default:WebSocketMap",
"@type": "WebSocketMap"
},
{
"comment": "Stores the opened WebSockets for reuse.",
"@id": "urn:solid-server:default:WebSocket2021Storer",
"@type": "WebSocket2021Storer",
"storage": { "@id": "urn:solid-server:default:SubscriptionStorage" },
"socketMap": { "@id": "urn:solid-server:default:WebSocketMap" }
},
{
"comment": "Handles the state feature of a WebSocketSubscription2021 subscription.",
"@id": "urn:solid-server:default:WebSocket2021StateHandler",
"@type": "BaseStateHandler",
"handler": { "@id": "urn:solid-server:default:WebSocket2021NotificationHandler" },
"storage": { "@id": "urn:solid-server:default:SubscriptionStorage" }
},
{
"@id": "urn:solid-server:default:ServerConfigurator",
"@type": "ParallelHandler",
"handlers": [
{ "@id": "urn:solid-server:default:WebSocket2021Listener" }
]
}
]
}

View File

@ -0,0 +1,41 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld",
"@graph": [
{
"comment": "Handles the subscriptions targeting a WebSocketSubscription2021.",
"@id": "urn:solid-server:default:WebSocket2021Subscriber",
"@type": "OperationRouterHandler",
"baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" },
"allowedMethods": [ "POST" ],
"allowedPathNames": [ "/WebSocketSubscription2021/" ],
"handler": {
"@type": "NotificationSubscriber",
"subscriptionType": { "@id": "urn:solid-server:default:WebSocketSubscription2021" },
"credentialsExtractor": { "@id": "urn:solid-server:default:CredentialsExtractor" },
"permissionReader": { "@id": "urn:solid-server:default:PermissionReader" },
"authorizer": { "@id": "urn:solid-server:default:Authorizer" }
}
},
{
"@id": "urn:solid-server:default:WebSocket2021Route",
"@type": "RelativePathInteractionRoute",
"base": { "@id": "urn:solid-server:default:NotificationRoute" },
"relativePath": "/WebSocketSubscription2021/"
},
{
"comment": "Contains all the metadata relevant for a WebSocketSubscription2021.",
"@id": "urn:solid-server:default:WebSocketSubscription2021",
"@type": "WebSocketSubscription2021",
"storage": { "@id": "urn:solid-server:default:SubscriptionStorage" },
"route": { "@id": "urn:solid-server:default:WebSocket2021Route" }
},
{
"@id": "urn:solid-server:default:NotificationTypeHandler",
"@type": "WaterfallHandler",
"handlers": [
{ "@id": "urn:solid-server:default:WebSocket2021Subscriber" }
]
}
]
}

View File

@ -7,7 +7,7 @@
"css:config/app/variables/default.json",
"css:config/http/handler/default.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/legacy-websockets.json",
"css:config/http/notifications/websockets.json",
"css:config/http/server-factory/https.json",
"css:config/http/static/default.json",
"css:config/identity/access/public.json",

View File

@ -8,7 +8,7 @@
"targetExtractor": { "@id": "urn:solid-server:default:TargetExtractor" },
"identifierStrategy": { "@id": "urn:solid-server:default:IdentifierStrategy" },
"store": { "@id": "urn:solid-server:default:ResourceStore" },
"suffix": { "@id": "urn:solid-server:default:variable:storageDescriptionSuffix" }
"path": { "@id": "urn:solid-server:default:variable:storageDescriptionPath" }
}
]
}

View File

@ -7,7 +7,7 @@
"css:config/app/variables/default.json",
"css:config/http/handler/default.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/legacy-websockets.json",
"css:config/http/notifications/websockets.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/access/public.json",

View File

@ -7,7 +7,7 @@
"css:config/app/variables/default.json",
"css:config/http/handler/default.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/legacy-websockets.json",
"css:config/http/notifications/websockets.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/access/public.json",

View File

@ -7,7 +7,7 @@
"css:config/app/variables/default.json",
"css:config/http/handler/default.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/legacy-websockets.json",
"css:config/http/notifications/websockets.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/access/public.json",

View File

@ -7,7 +7,7 @@
"css:config/app/variables/default.json",
"css:config/http/handler/default.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/legacy-websockets.json",
"css:config/http/notifications/websockets.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/access/restricted.json",

View File

@ -7,7 +7,7 @@
"css:config/app/variables/default.json",
"css:config/http/handler/default.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/legacy-websockets.json",
"css:config/http/notifications/websockets.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/access/public.json",

View File

@ -7,7 +7,7 @@
"css:config/app/variables/default.json",
"css:config/http/handler/default.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/legacy-websockets.json",
"css:config/http/notifications/websockets.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/access/public.json",

View File

@ -7,7 +7,7 @@
"css:config/app/variables/default.json",
"css:config/http/handler/default.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/legacy-websockets.json",
"css:config/http/notifications/websockets.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/access/public.json",

View File

@ -13,7 +13,7 @@ import { MetadataWriter } from './MetadataWriter';
/**
* Adds a link header pointing to the relevant storage description resource.
* Recursively checks parent containers until a storage container is found,
* and then appends the provided suffix to determine the storage description resource.
* and then appends the provided relative path to determine the storage description resource.
*/
export class StorageDescriptionAdvertiser extends MetadataWriter {
protected readonly logger = getLoggerFor(this);
@ -21,15 +21,15 @@ export class StorageDescriptionAdvertiser extends MetadataWriter {
private readonly targetExtractor: TargetExtractor;
private readonly identifierStrategy: IdentifierStrategy;
private readonly store: ResourceStore;
private readonly suffix: string;
private readonly path: string;
public constructor(targetExtractor: TargetExtractor, identifierStrategy: IdentifierStrategy, store: ResourceStore,
suffix: string) {
path: string) {
super();
this.identifierStrategy = identifierStrategy;
this.targetExtractor = targetExtractor;
this.store = store;
this.suffix = suffix;
this.path = path;
}
public async handle({ response, metadata }: MetadataWriterInput): Promise<void> {
@ -45,7 +45,7 @@ export class StorageDescriptionAdvertiser extends MetadataWriter {
this.logger.error(`Unable to find storage root: ${createErrorMessage(error)}`);
return;
}
const storageDescription = joinUrl(storageRoot.path, this.suffix);
const storageDescription = joinUrl(storageRoot.path, this.path);
addHeader(response, 'Link', `<${storageDescription}>; rel="${SOLID.storageDescription}"`);
}

View File

@ -317,6 +317,15 @@ export * from './server/notifications/generate/StateNotificationGenerator';
export * from './server/notifications/serialize/ConvertingNotificationSerializer';
export * from './server/notifications/serialize/JsonLdNotificationSerializer';
export * from './server/notifications/serialize/NotificationSerializer';
// Server/Notifications/WebSocketSubscription2021
export * from './server/notifications/WebSocketSubscription2021/WebSocket2021Emitter';
export * from './server/notifications/WebSocketSubscription2021/WebSocket2021Handler';
export * from './server/notifications/WebSocketSubscription2021/WebSocket2021Listener';
export * from './server/notifications/WebSocketSubscription2021/WebSocket2021Storer';
export * from './server/notifications/WebSocketSubscription2021/WebSocketMap';
export * from './server/notifications/WebSocketSubscription2021/WebSocketSubscription2021';
// Server/Notifications
export * from './server/notifications/ActivityEmitter';
export * from './server/notifications/BaseStateHandler';

View File

@ -9,6 +9,9 @@ import namedNode = DataFactory.namedNode;
/**
* Adds a fixed set of triples to the storage description resource,
* with the resource identifier as subject.
*
* This can be used to add descriptions that a storage always needs to have,
* such as the `<> a pim:Storage` triple.
*/
export class StaticStorageDescriber extends StorageDescriber {
private readonly terms: ReadonlyMap<NamedNode, Quad_Object[]>;

View File

@ -14,20 +14,20 @@ import type { StorageDescriber } from './StorageDescriber';
/**
* Generates the response for GET requests targeting a storage description resource.
* The suffix needs to match the suffix used to generate storage description resources
* and will be used to verify the container it is linked to is an actual storage.
* The input path needs to match the relative path used to generate storage description resources
* and will be used to verify if the container it is linked to is an actual storage.
*/
export class StorageDescriptionHandler extends OperationHttpHandler {
private readonly store: ResourceStore;
private readonly suffix: string;
private readonly path: string;
private readonly converter: RepresentationConverter;
private readonly describer: StorageDescriber;
public constructor(store: ResourceStore, suffix: string, converter: RepresentationConverter,
public constructor(store: ResourceStore, path: string, converter: RepresentationConverter,
describer: StorageDescriber) {
super();
this.store = store;
this.suffix = suffix;
this.path = path;
this.converter = converter;
this.describer = describer;
}
@ -36,7 +36,7 @@ export class StorageDescriptionHandler extends OperationHttpHandler {
if (method !== 'GET') {
throw new MethodNotAllowedHttpError([ method ], `Only GET requests can target the storage description.`);
}
const container = { path: ensureTrailingSlash(target.path.slice(0, -this.suffix.length)) };
const container = { path: ensureTrailingSlash(target.path.slice(0, -this.path.length)) };
const representation = await this.store.getRepresentation(container, {});
representation.data.destroy();
if (!representation.metadata.has(RDF.terms.type, PIM.terms.Storage)) {

View File

@ -2,7 +2,7 @@ export const CONTEXT_ACTIVITYSTREAMS = 'https://www.w3.org/ns/activitystreams';
export const CONTEXT_NOTIFICATION = 'https://www.w3.org/ns/solid/notification/v1';
/**
* The minimal expected fields for a Notification
* The minimally expected fields for a Notification
* as defined in https://solidproject.org/TR/notifications-protocol#notification-data-model.
*/
export interface Notification {

View File

@ -0,0 +1,36 @@
import type { WebSocket } from 'ws';
import { getLoggerFor } from '../../../logging/LogUtil';
import type { SetMultiMap } from '../../../util/map/SetMultiMap';
import { readableToString } from '../../../util/StreamUtil';
import { NotificationEmitter } from '../NotificationEmitter';
import type { NotificationEmitterInput } from '../NotificationEmitter';
/**
* Emits notifications on WebSocketSubscription2021 subscription.
* Uses the WebSockets found in the provided map.
* The key should be the identifier of the matching subscription.
*/
export class WebSocket2021Emitter extends NotificationEmitter {
protected readonly logger = getLoggerFor(this);
private readonly socketMap: SetMultiMap<string, WebSocket>;
public constructor(socketMap: SetMultiMap<string, WebSocket>) {
super();
this.socketMap = socketMap;
}
public async handle({ info, representation }: NotificationEmitterInput): Promise<void> {
// Called as a NotificationEmitter: emit the notification
const webSockets = this.socketMap.get(info.id);
if (webSockets) {
const data = await readableToString(representation.data);
for (const webSocket of webSockets) {
webSocket.send(data);
}
} else {
representation.data.destroy();
}
}
}

View File

@ -0,0 +1,13 @@
import type { WebSocket } from 'ws';
import { AsyncHandler } from '../../../util/handlers/AsyncHandler';
import type { SubscriptionInfo } from '../SubscriptionStorage';
export interface WebSocket2021HandlerInput {
info: SubscriptionInfo;
webSocket: WebSocket;
}
/**
* A handler that is called when a valid WebSocketSubscription2021 connection has been made.
*/
export abstract class WebSocket2021Handler extends AsyncHandler<WebSocket2021HandlerInput> {}

View File

@ -0,0 +1,56 @@
import type { IncomingMessage } from 'http';
import type { WebSocket } from 'ws';
import type { InteractionRoute } from '../../../identity/interaction/routing/InteractionRoute';
import { getLoggerFor } from '../../../logging/LogUtil';
import { WebSocketServerConfigurator } from '../../WebSocketServerConfigurator';
import type { SubscriptionStorage } from '../SubscriptionStorage';
import type { WebSocket2021Handler } from './WebSocket2021Handler';
/**
* Listens for WebSocket connections and verifies if they are valid WebSocketSubscription2021 connections,
* in which case its {@link WebSocket2021Handler} will be alerted.
*/
export class WebSocket2021Listener extends WebSocketServerConfigurator {
protected readonly logger = getLoggerFor(this);
private readonly storage: SubscriptionStorage;
private readonly handler: WebSocket2021Handler;
private readonly path: string;
public constructor(storage: SubscriptionStorage, handler: WebSocket2021Handler, route: InteractionRoute) {
super();
this.storage = storage;
this.handler = handler;
this.path = new URL(route.getPath()).pathname;
}
protected async handleConnection(webSocket: WebSocket, upgradeRequest: IncomingMessage): Promise<void> {
// Base doesn't matter since we just want the path and query parameter
const { pathname, searchParams } = new URL(upgradeRequest.url ?? '', 'http://example.com');
if (pathname !== this.path) {
webSocket.send('Unknown WebSocket target.');
return webSocket.close();
}
const auth = searchParams.get('auth');
if (!auth) {
webSocket.send('Missing auth parameter from WebSocket URL.');
return webSocket.close();
}
const id = decodeURI(auth);
const info = await this.storage.get(id);
if (!info) {
// Info not being there implies it has expired
webSocket.send(`Subscription has expired`);
return webSocket.close();
}
this.logger.info(`Accepted WebSocket connection listening to changes on ${info.topic}`);
await this.handler.handleSafe({ info, webSocket });
}
}

View File

@ -0,0 +1,58 @@
import type { WebSocket } from 'ws';
import { getLoggerFor } from '../../../logging/LogUtil';
import type { SetMultiMap } from '../../../util/map/SetMultiMap';
import { setSafeInterval } from '../../../util/TimerUtil';
import type { SubscriptionStorage } from '../SubscriptionStorage';
import type { WebSocket2021HandlerInput } from './WebSocket2021Handler';
import { WebSocket2021Handler } from './WebSocket2021Handler';
/**
* Keeps track of the WebSockets that were opened for a WebSocketSubscription2021 subscription.
* The WebSockets are stored in the map using the identifier of the matching subscription.
*
* `cleanupTimer` defines in minutes how often the stored WebSockets are closed
* if their corresponding subscription has expired.
* Defaults to 60 minutes.
* Open WebSockets will not receive notifications if their subscription expired.
*/
export class WebSocket2021Storer extends WebSocket2021Handler {
protected readonly logger = getLoggerFor(this);
private readonly storage: SubscriptionStorage;
private readonly socketMap: SetMultiMap<string, WebSocket>;
public constructor(storage: SubscriptionStorage, socketMap: SetMultiMap<string, WebSocket>, cleanupTimer = 60) {
super();
this.socketMap = socketMap;
this.storage = storage;
const timer = setSafeInterval(this.logger,
'Failed to remove closed WebSockets',
this.closeExpiredSockets.bind(this),
cleanupTimer * 60 * 1000);
timer.unref();
}
public async handle({ webSocket, info }: WebSocket2021HandlerInput): Promise<void> {
this.socketMap.add(info.id, webSocket);
webSocket.on('error', (): boolean => this.socketMap.deleteEntry(info.id, webSocket));
webSocket.on('close', (): boolean => this.socketMap.deleteEntry(info.id, webSocket));
}
/**
* Close all WebSockets that are attached to a subscription that no longer exists.
*/
private async closeExpiredSockets(): Promise<void> {
this.logger.debug('Closing expired WebSockets');
for (const [ id, sockets ] of this.socketMap.entrySets()) {
const result = await this.storage.get(id);
if (!result) {
for (const socket of sockets) {
// Due to the attached listener this also deletes the entries
socket.close();
}
}
}
this.logger.debug('Finished closing expired WebSockets');
}
}

View File

@ -0,0 +1,10 @@
import type { WebSocket } from 'ws';
import type { SingleThreaded } from '../../../init/cluster/SingleThreaded';
import { WrappedSetMultiMap } from '../../../util/map/WrappedSetMultiMap';
/**
* A {@link SetMultiMap} linking identifiers to a set of WebSockets.
* An extension of {@link WrappedSetMultiMap} to make sure Components.js allows us to create this in the config,
* as {@link WrappedSetMultiMap} has a constructor not supported.
*/
export class WebSocketMap extends WrappedSetMultiMap<string, WebSocket> implements SingleThreaded {}

View File

@ -0,0 +1,57 @@
import { string } from 'yup';
import type { AccessMap } from '../../../authorization/permissions/Permissions';
import { AccessMode } from '../../../authorization/permissions/Permissions';
import { BasicRepresentation } from '../../../http/representation/BasicRepresentation';
import type { InteractionRoute } from '../../../identity/interaction/routing/InteractionRoute';
import { getLoggerFor } from '../../../logging/LogUtil';
import { APPLICATION_LD_JSON } from '../../../util/ContentTypes';
import { IdentifierSetMultiMap } from '../../../util/map/IdentifierMap';
import { CONTEXT_NOTIFICATION } from '../Notification';
import type { Subscription } from '../Subscription';
import { SUBSCRIBE_SCHEMA } from '../Subscription';
import type { SubscriptionStorage } from '../SubscriptionStorage';
import type { SubscriptionResponse, SubscriptionType } from '../SubscriptionType';
const type = 'WebSocketSubscription2021';
const schema = SUBSCRIBE_SCHEMA.shape({
type: string().required().oneOf([ type ]),
});
/**
* The notification subscription type WebSocketSubscription2021 as described in
* https://solidproject.org/TR/websocket-subscription-2021
*
* Requires read permissions on a resource to be able to receive notifications.
*/
export class WebSocketSubscription2021 implements SubscriptionType<typeof schema> {
protected readonly logger = getLoggerFor(this);
private readonly storage: SubscriptionStorage;
private readonly path: string;
public readonly type = type;
public readonly schema = schema;
public constructor(storage: SubscriptionStorage, route: InteractionRoute) {
this.storage = storage;
this.path = route.getPath();
}
public async extractModes(subscription: Subscription): Promise<AccessMap> {
return new IdentifierSetMultiMap<AccessMode>([[{ path: subscription.topic }, AccessMode.read ]]);
}
public async subscribe(subscription: Subscription): Promise<SubscriptionResponse> {
const info = this.storage.create(subscription, {});
await this.storage.add(info);
const jsonld = {
'@context': [ CONTEXT_NOTIFICATION ],
type: this.type,
source: `ws${this.path.slice('http'.length)}?auth=${encodeURI(info.id)}`,
};
const response = new BasicRepresentation(JSON.stringify(jsonld), APPLICATION_LD_JSON);
return { response, info };
}
}

View File

@ -0,0 +1,268 @@
import { fetch } from 'cross-fetch';
import type { NamedNode } from 'n3';
import { DataFactory, Parser, Store } from 'n3';
import { WebSocket } from 'ws';
import { BasicRepresentation } from '../../src/http/representation/BasicRepresentation';
import type { App } from '../../src/init/App';
import type { ResourceStore } from '../../src/storage/ResourceStore';
import { joinUrl } from '../../src/util/PathUtil';
import { NOTIFY, RDF } from '../../src/util/Vocabularies';
import { getPort } from '../util/Util';
import {
getDefaultVariables,
getPresetConfigPath,
getTestConfigPath,
getTestFolder,
instantiateFromConfig, removeFolder,
} from './Config';
import quad = DataFactory.quad;
import namedNode = DataFactory.namedNode;
const port = getPort('WebSocketSubscription2021');
const baseUrl = `http://localhost:${port}/`;
const rootFilePath = getTestFolder('WebSocketSubscription2021');
const stores: [string, any][] = [
[ 'in-memory storage', {
configs: [ 'storage/backend/memory.json', 'util/resource-locker/memory.json' ],
teardown: jest.fn(),
}],
[ 'on-disk storage', {
// Switch to file locker after https://github.com/CommunitySolidServer/CommunitySolidServer/issues/1452
configs: [ 'storage/backend/file.json', 'util/resource-locker/memory.json' ],
teardown: async(): Promise<void> => removeFolder(rootFilePath),
}],
];
// Send the subscribe request and check the response
async function subscribe(subscriptionUrl: string, topic: string, features: Record<string, unknown> = {}):
Promise<string> {
const subscription = {
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
type: 'WebSocketSubscription2021',
topic,
...features,
};
const response = await fetch(subscriptionUrl, {
method: 'POST',
headers: { 'content-type': 'application/ld+json' },
body: JSON.stringify(subscription),
});
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('application/ld+json');
const { type, source } = await response.json();
expect(type).toBe('WebSocketSubscription2021');
return source;
}
// Check if a notification has the correct format
function expectNotification(notification: unknown, topic: string, type: 'Create' | 'Update' | 'Delete'): void {
const expected: any = {
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://www.w3.org/ns/solid/notification/v1',
],
id: expect.stringContaining(topic),
type: [ type ],
object: {
id: topic,
type: [],
},
published: expect.anything(),
};
if (type !== 'Delete') {
expected.state = expect.anything();
expected.object.type.push('http://www.w3.org/ns/ldp#Resource');
}
expect(notification).toEqual(expected);
}
describe.each(stores)('A server supporting WebSocketSubscription2021 using %s', (name, { configs, teardown }): void => {
let app: App;
let store: ResourceStore;
const webId = 'http://example.com/card/#me';
const topic = joinUrl(baseUrl, '/foo');
let storageDescriptionUrl: string;
let subscriptionUrl: string;
let webSocketUrl: string;
beforeAll(async(): Promise<void> => {
const variables = {
...getDefaultVariables(port, baseUrl),
'urn:solid-server:default:variable:rootFilePath': rootFilePath,
};
// Create and start the server
const instances = await instantiateFromConfig(
'urn:solid-server:test:Instances',
[
...configs.map(getPresetConfigPath),
getTestConfigPath('websocket-notifications.json'),
],
variables,
) as Record<string, any>;
({ app, store } = instances);
await app.start();
});
afterAll(async(): Promise<void> => {
await teardown();
await app.stop();
});
it('links to the storage description.', async(): Promise<void> => {
const response = await fetch(baseUrl);
expect(response.status).toBe(200);
const linkHeader = response.headers.get('link');
expect(linkHeader).not.toBeNull();
const match = /<([^>]+)>; rel="http:\/\/www\.w3\.org\/ns\/solid\/terms#storageDescription"/u.exec(linkHeader!);
expect(match).not.toBeNull();
storageDescriptionUrl = match![1];
});
it('exposes metadata on how to subscribe in the storage description.', async(): Promise<void> => {
const response = await fetch(storageDescriptionUrl, { headers: { accept: 'text/turtle' }});
expect(response.status).toBe(200);
const quads = new Store(new Parser().parse(await response.text()));
// Find the notification channel for websockets
const channels = quads.getObjects(storageDescriptionUrl, NOTIFY.terms.notificationChannel, null);
const websocketChannels = channels.filter((channel): boolean => quads.has(
quad(channel as NamedNode, RDF.terms.type, namedNode(`${NOTIFY.namespace}WebSocketSubscription2021`)),
));
expect(websocketChannels).toHaveLength(1);
const subscriptionUrls = quads.getObjects(websocketChannels[0], NOTIFY.terms.subscription, null);
expect(subscriptionUrls).toHaveLength(1);
subscriptionUrl = subscriptionUrls[0].value;
});
it('supports subscribing.', async(): Promise<void> => {
webSocketUrl = await subscribe(subscriptionUrl, topic);
});
it('emits Created events.', async(): Promise<void> => {
const socket = new WebSocket(webSocketUrl);
const notificationPromise = new Promise<Buffer>((resolve): any => socket.on('message', resolve));
await new Promise<void>((resolve): any => socket.on('open', resolve));
const response = await fetch(topic, {
method: 'PUT',
headers: { 'content-type': 'text/plain' },
body: 'abc',
});
expect(response.status).toBe(201);
const notification = JSON.parse((await notificationPromise).toString());
socket.close();
expectNotification(notification, topic, 'Create');
});
it('emits Update events.', async(): Promise<void> => {
const socket = new WebSocket(webSocketUrl);
const notificationPromise = new Promise<Buffer>((resolve): any => socket.on('message', resolve));
await new Promise<void>((resolve): any => socket.on('open', resolve));
const response = await fetch(topic, {
method: 'PUT',
headers: { 'content-type': 'text/plain' },
body: 'def',
});
expect(response.status).toBe(205);
const notification = JSON.parse((await notificationPromise).toString());
socket.close();
expectNotification(notification, topic, 'Update');
});
it('emits Delete events.', async(): Promise<void> => {
const socket = new WebSocket(webSocketUrl);
const notificationPromise = new Promise<Buffer>((resolve): any => socket.on('message', resolve));
await new Promise<void>((resolve): any => socket.on('open', resolve));
const response = await fetch(topic, { method: 'DELETE' });
expect(response.status).toBe(205);
const notification = JSON.parse((await notificationPromise).toString());
socket.close();
expectNotification(notification, topic, 'Delete');
});
it('prevents subscribing to restricted resources.', async(): Promise<void> => {
const restricted = joinUrl(baseUrl, '/restricted');
// Only allow our WebID to read
const restrictedAcl = `@prefix acl: <http://www.w3.org/ns/auth/acl#>.
@prefix foaf: <http://xmlns.com/foaf/0.1/>.
<#authorization>
a acl:Authorization;
acl:agent <${webId}>;
acl:mode acl:Read;
acl:accessTo <./restricted>.`;
await store.setRepresentation({ path: `${restricted}.acl` }, new BasicRepresentation(restrictedAcl, 'text/turtle'));
const subscription = {
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
type: 'WebSocketSubscription2021',
topic: restricted,
};
// Unauthenticated fetch fails
let response = await fetch(subscriptionUrl, {
method: 'POST',
headers: { 'content-type': 'application/ld+json' },
body: JSON.stringify(subscription),
});
expect(response.status).toBe(401);
// (debug) Authenticated fetch succeeds
response = await fetch(subscriptionUrl, {
method: 'POST',
headers: {
authorization: `WebID ${webId}`,
'content-type': 'application/ld+json',
},
body: JSON.stringify(subscription),
});
expect(response.status).toBe(200);
});
it('sends a notification if a state value was sent along.', async(): Promise<void> => {
const response = await fetch(topic, {
method: 'PUT',
headers: { 'content-type': 'text/plain' },
body: 'abc',
});
expect(response.status).toBe(201);
const source = await subscribe(subscriptionUrl, topic, { state: 'abc' });
const socket = new WebSocket(source);
const notificationPromise = new Promise<Buffer>((resolve): any => socket.on('message', resolve));
await new Promise<void>((resolve): any => socket.on('open', resolve));
// Will receive a notification even though the resource did not change after the socket was open
const notification = JSON.parse((await notificationPromise).toString());
socket.close();
expectNotification(notification, topic, 'Update');
});
it('removes expired subscriptions.', async(): Promise<void> => {
const source = await subscribe(subscriptionUrl, topic, { expiration: 1 });
const socket = new WebSocket(source);
const messagePromise = new Promise<Buffer>((resolve): any => socket.on('message', resolve));
await new Promise<void>((resolve): any => socket.on('close', resolve));
const message = (await messagePromise).toString();
expect(message).toBe('Subscription has expired');
});
});

View File

@ -4,9 +4,9 @@
"css:config/app/main/default.json",
"css:config/app/init/default.json",
"css:config/app/setup/disabled.json",
"css:config/http/handler/simple.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/disabled.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/access/public.json",

View File

@ -6,7 +6,7 @@
"css:config/app/setup/disabled.json",
"css:config/http/handler/default.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/legacy-websockets.json",
"css:config/http/notifications/websockets.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/access/public.json",

View File

@ -6,7 +6,7 @@
"css:config/app/setup/disabled.json",
"css:config/http/handler/default.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/legacy-websockets.json",
"css:config/http/notifications/websockets.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/access/public.json",

View File

@ -6,7 +6,7 @@
"css:config/app/setup/disabled.json",
"css:config/http/handler/default.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/legacy-websockets.json",
"css:config/http/notifications/websockets.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/access/restricted.json",

View File

@ -6,7 +6,7 @@
"css:config/app/setup/disabled.json",
"css:config/http/handler/default.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/legacy-websockets.json",
"css:config/http/notifications/websockets.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/access/public.json",

View File

@ -6,7 +6,7 @@
"css:config/app/setup/disabled.json",
"css:config/http/handler/default.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/legacy-websockets.json",
"css:config/http/notifications/websockets.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/access/public.json",

View File

@ -6,7 +6,7 @@
"css:config/app/setup/disabled.json",
"css:config/http/handler/simple.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/legacy-websockets.json",
"css:config/http/notifications/websockets.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/access/public.json",

View File

@ -6,7 +6,7 @@
"css:config/app/setup/disabled.json",
"css:config/http/handler/simple.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/legacy-websockets.json",
"css:config/http/notifications/websockets.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",

View File

@ -6,7 +6,7 @@
"css:config/app/setup/required.json",
"css:config/http/handler/default.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/legacy-websockets.json",
"css:config/http/notifications/websockets.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/access/public.json",

View File

@ -0,0 +1,52 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld",
"import": [
"css:config/app/main/default.json",
"css:config/app/init/initialize-root.json",
"css:config/app/setup/disabled.json",
"css:config/http/handler/default.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/websockets.json",
"css:config/http/server-factory/http.json",
"css:config/http/static/default.json",
"css:config/identity/access/public.json",
"css:config/identity/email/default.json",
"css:config/identity/handler/default.json",
"css:config/identity/ownership/token.json",
"css:config/identity/pod/static.json",
"css:config/identity/registration/enabled.json",
"css:config/ldp/authentication/debug-auth-header.json",
"css:config/ldp/authorization/webacl.json",
"css:config/ldp/handler/default.json",
"css:config/ldp/metadata-parser/default.json",
"css:config/ldp/metadata-writer/default.json",
"css:config/ldp/modes/default.json",
"css:config/storage/key-value/resource-store.json",
"css:config/storage/middleware/default.json",
"css:config/util/auxiliary/acl.json",
"css:config/util/identifiers/suffix.json",
"css:config/util/index/default.json",
"css:config/util/logging/winston.json",
"css:config/util/representation-conversion/default.json",
"css:config/util/variables/default.json"
],
"@graph": [
{
"comment": "WebSocket notifications with debug authentication.",
"@id": "urn:solid-server:test:Instances",
"@type": "RecordObject",
"record": [
{
"RecordObject:_record_key": "app",
"RecordObject:_record_value": { "@id": "urn:solid-server:default:App" }
},
{
"RecordObject:_record_key": "store",
"RecordObject:_record_value": { "@id": "urn:solid-server:default:ResourceStore" }
}
]
}
]
}

View File

@ -15,7 +15,7 @@ describe('A StorageDescriptionAdvertiser', (): void => {
let response: jest.Mocked<HttpResponse>;
let input: MetadataWriterInput;
const baseUrl = 'http://example.com/';
const suffix = '.well-known/solid';
const path = '.well-known/solid';
let targetExtractor: jest.Mocked<TargetExtractor>;
const identifierStrategy = new SingleRootIdentifierStrategy(baseUrl);
let store: jest.Mocked<ResourceStore>;
@ -41,7 +41,7 @@ describe('A StorageDescriptionAdvertiser', (): void => {
getRepresentation: jest.fn().mockResolvedValue(new BasicRepresentation('', { [RDF.type]: PIM.terms.Storage })),
} as any;
advertiser = new StorageDescriptionAdvertiser(targetExtractor, identifierStrategy, store, suffix);
advertiser = new StorageDescriptionAdvertiser(targetExtractor, identifierStrategy, store, path);
});
it('adds a storage description link header.', async(): Promise<void> => {

View File

@ -15,7 +15,7 @@ import quad = DataFactory.quad;
import namedNode = DataFactory.namedNode;
describe('A StorageDescriptionHandler', (): void => {
const suffix = '.well-known/solid';
const path = '.well-known/solid';
const request: HttpRequest = {} as any;
const response: HttpResponse = {} as any;
let operation: Operation;
@ -28,7 +28,7 @@ describe('A StorageDescriptionHandler', (): void => {
beforeEach(async(): Promise<void> => {
operation = {
method: 'GET',
target: { path: `http://example.com/${suffix}` },
target: { path: `http://example.com/${path}` },
body: new BasicRepresentation(),
preferences: {},
};
@ -50,7 +50,7 @@ describe('A StorageDescriptionHandler', (): void => {
[ quad(namedNode(target.path), RDF.terms.type, PIM.terms.Storage) ]),
} as any;
handler = new StorageDescriptionHandler(store, suffix, converter, describer);
handler = new StorageDescriptionHandler(store, path, converter, describer);
});
it('only handles GET requests.', async(): Promise<void> => {

View File

@ -0,0 +1,84 @@
import { EventEmitter } from 'events';
import type { WebSocket } from 'ws';
import { BasicRepresentation } from '../../../../../src/http/representation/BasicRepresentation';
import type {
SubscriptionInfo,
} from '../../../../../src/server/notifications/SubscriptionStorage';
import {
WebSocket2021Emitter,
} from '../../../../../src/server/notifications/WebSocketSubscription2021/WebSocket2021Emitter';
import type { SetMultiMap } from '../../../../../src/util/map/SetMultiMap';
import { WrappedSetMultiMap } from '../../../../../src/util/map/WrappedSetMultiMap';
describe('A WebSocket2021Emitter', (): void => {
const info: SubscriptionInfo = {
id: 'id',
topic: 'http://example.com/foo',
type: 'type',
features: {},
lastEmit: 0,
};
let webSocket: jest.Mocked<WebSocket>;
let socketMap: SetMultiMap<string, WebSocket>;
let emitter: WebSocket2021Emitter;
beforeEach(async(): Promise<void> => {
webSocket = new EventEmitter() as any;
webSocket.send = jest.fn();
webSocket.close = jest.fn();
socketMap = new WrappedSetMultiMap();
emitter = new WebSocket2021Emitter(socketMap);
});
it('emits notifications to the stored WebSockets.', async(): Promise<void> => {
socketMap.add(info.id, webSocket);
const representation = new BasicRepresentation('notification', 'text/plain');
await expect(emitter.handle({ info, representation })).resolves.toBeUndefined();
expect(webSocket.send).toHaveBeenCalledTimes(1);
expect(webSocket.send).toHaveBeenLastCalledWith('notification');
});
it('destroys the representation if there is no matching WebSocket.', async(): Promise<void> => {
const representation = new BasicRepresentation('notification', 'text/plain');
await expect(emitter.handle({ info, representation })).resolves.toBeUndefined();
expect(webSocket.send).toHaveBeenCalledTimes(0);
expect(representation.data.destroyed).toBe(true);
});
it('can send to multiple matching WebSockets.', async(): Promise<void> => {
const webSocket2: jest.Mocked<WebSocket> = new EventEmitter() as any;
webSocket2.send = jest.fn();
socketMap.add(info.id, webSocket);
socketMap.add(info.id, webSocket2);
const representation = new BasicRepresentation('notification', 'text/plain');
await expect(emitter.handle({ info, representation })).resolves.toBeUndefined();
expect(webSocket.send).toHaveBeenCalledTimes(1);
expect(webSocket.send).toHaveBeenLastCalledWith('notification');
expect(webSocket2.send).toHaveBeenCalledTimes(1);
expect(webSocket2.send).toHaveBeenLastCalledWith('notification');
});
it('only sends to the matching WebSockets.', async(): Promise<void> => {
const webSocket2: jest.Mocked<WebSocket> = new EventEmitter() as any;
webSocket2.send = jest.fn();
const info2: SubscriptionInfo = {
...info,
id: 'other',
};
socketMap.add(info.id, webSocket);
socketMap.add(info2.id, webSocket2);
const representation = new BasicRepresentation('notification', 'text/plain');
await expect(emitter.handle({ info, representation })).resolves.toBeUndefined();
expect(webSocket.send).toHaveBeenCalledTimes(1);
expect(webSocket.send).toHaveBeenLastCalledWith('notification');
expect(webSocket2.send).toHaveBeenCalledTimes(0);
});
});

View File

@ -0,0 +1,124 @@
import { EventEmitter } from 'events';
import type { Server } from 'http';
import type { WebSocket } from 'ws';
import {
AbsolutePathInteractionRoute,
} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute';
import type { HttpRequest } from '../../../../../src/server/HttpRequest';
import type {
SubscriptionInfo,
SubscriptionStorage,
} from '../../../../../src/server/notifications/SubscriptionStorage';
import type {
WebSocket2021Handler,
} from '../../../../../src/server/notifications/WebSocketSubscription2021/WebSocket2021Handler';
import {
WebSocket2021Listener,
} from '../../../../../src/server/notifications/WebSocketSubscription2021/WebSocket2021Listener';
import { flushPromises } from '../../../../util/Util';
jest.mock('ws', (): any => ({
// eslint-disable-next-line @typescript-eslint/naming-convention
WebSocketServer: jest.fn().mockImplementation((): any => ({
handleUpgrade(upgradeRequest: any, socket: any, head: any, callback: any): void {
callback(socket, upgradeRequest);
},
})),
}));
describe('A WebSocket2021Listener', (): void => {
const info: SubscriptionInfo = {
id: 'id',
topic: 'http://example.com/foo',
type: 'type',
features: {},
lastEmit: 0,
};
const auth = '123456';
let server: Server;
let webSocket: WebSocket;
let upgradeRequest: HttpRequest;
let storage: jest.Mocked<SubscriptionStorage>;
let handler: jest.Mocked<WebSocket2021Handler>;
const route = new AbsolutePathInteractionRoute('http://example.com/foo');
let listener: WebSocket2021Listener;
beforeEach(async(): Promise<void> => {
server = new EventEmitter() as any;
webSocket = new EventEmitter() as any;
webSocket.send = jest.fn();
webSocket.close = jest.fn();
upgradeRequest = { url: `/foo?auth=${auth}` } as any;
storage = {
get: jest.fn().mockResolvedValue(info),
} as any;
handler = {
handleSafe: jest.fn(),
} as any;
listener = new WebSocket2021Listener(storage, handler, route);
await listener.handle(server);
});
it('rejects request targeting an unknown path.', async(): Promise<void> => {
upgradeRequest.url = '/wrong';
server.emit('upgrade', upgradeRequest, webSocket);
await flushPromises();
expect(webSocket.send).toHaveBeenCalledTimes(1);
expect(webSocket.send).toHaveBeenLastCalledWith('Unknown WebSocket target.');
expect(webSocket.close).toHaveBeenCalledTimes(1);
expect(handler.handleSafe).toHaveBeenCalledTimes(0);
});
it('rejects request with no url.', async(): Promise<void> => {
delete upgradeRequest.url;
server.emit('upgrade', upgradeRequest, webSocket);
await flushPromises();
expect(webSocket.send).toHaveBeenCalledTimes(1);
expect(webSocket.send).toHaveBeenLastCalledWith('Unknown WebSocket target.');
expect(webSocket.close).toHaveBeenCalledTimes(1);
expect(handler.handleSafe).toHaveBeenCalledTimes(0);
});
it('rejects requests without an auth parameter.', async(): Promise<void> => {
upgradeRequest.url = '/foo';
server.emit('upgrade', upgradeRequest, webSocket);
await flushPromises();
expect(webSocket.send).toHaveBeenCalledTimes(1);
expect(webSocket.send).toHaveBeenLastCalledWith('Missing auth parameter from WebSocket URL.');
expect(webSocket.close).toHaveBeenCalledTimes(1);
expect(handler.handleSafe).toHaveBeenCalledTimes(0);
});
it('rejects requests with an unknown auth parameter.', async(): Promise<void> => {
storage.get.mockResolvedValue(undefined);
server.emit('upgrade', upgradeRequest, webSocket);
await flushPromises();
expect(webSocket.send).toHaveBeenCalledTimes(1);
expect(webSocket.send).toHaveBeenLastCalledWith(`Subscription has expired`);
expect(webSocket.close).toHaveBeenCalledTimes(1);
expect(handler.handleSafe).toHaveBeenCalledTimes(0);
});
it('calls the handler when receiving a valid request.', async(): Promise<void> => {
server.emit('upgrade', upgradeRequest, webSocket);
await flushPromises();
expect(webSocket.send).toHaveBeenCalledTimes(0);
expect(webSocket.close).toHaveBeenCalledTimes(0);
expect(handler.handleSafe).toHaveBeenCalledTimes(1);
expect(handler.handleSafe).toHaveBeenLastCalledWith({ webSocket, info });
});
});

View File

@ -0,0 +1,96 @@
import { EventEmitter } from 'events';
import type { WebSocket } from 'ws';
import type {
SubscriptionInfo,
SubscriptionStorage,
} from '../../../../../src/server/notifications/SubscriptionStorage';
import {
WebSocket2021Storer,
} from '../../../../../src/server/notifications/WebSocketSubscription2021/WebSocket2021Storer';
import type { SetMultiMap } from '../../../../../src/util/map/SetMultiMap';
import { WrappedSetMultiMap } from '../../../../../src/util/map/WrappedSetMultiMap';
import { flushPromises } from '../../../../util/Util';
describe('A WebSocket2021Storer', (): void => {
const info: SubscriptionInfo = {
id: 'id',
topic: 'http://example.com/foo',
type: 'type',
features: {},
lastEmit: 0,
};
let webSocket: jest.Mocked<WebSocket>;
let storage: jest.Mocked<SubscriptionStorage>;
let socketMap: SetMultiMap<string, WebSocket>;
let storer: WebSocket2021Storer;
beforeEach(async(): Promise<void> => {
webSocket = new EventEmitter() as any;
webSocket.close = jest.fn();
storage = {
get: jest.fn(),
} as any;
socketMap = new WrappedSetMultiMap();
storer = new WebSocket2021Storer(storage, socketMap);
});
it('stores WebSockets.', async(): Promise<void> => {
await expect(storer.handle({ info, webSocket })).resolves.toBeUndefined();
expect([ ...socketMap.keys() ]).toHaveLength(1);
expect(socketMap.has(info.id)).toBe(true);
});
it('removes closed WebSockets.', async(): Promise<void> => {
await expect(storer.handle({ info, webSocket })).resolves.toBeUndefined();
expect(socketMap.has(info.id)).toBe(true);
webSocket.emit('close');
expect(socketMap.has(info.id)).toBe(false);
});
it('removes erroring WebSockets.', async(): Promise<void> => {
await expect(storer.handle({ info, webSocket })).resolves.toBeUndefined();
expect(socketMap.has(info.id)).toBe(true);
webSocket.emit('error');
expect(socketMap.has(info.id)).toBe(false);
});
it('removes expired WebSockets.', async(): Promise<void> => {
jest.useFakeTimers();
// Need to create class after fake timers have been enabled
storer = new WebSocket2021Storer(storage, socketMap);
const webSocket2: jest.Mocked<WebSocket> = new EventEmitter() as any;
webSocket2.close = jest.fn();
const webSocketOther: jest.Mocked<WebSocket> = new EventEmitter() as any;
webSocketOther.close = jest.fn();
const infoOther: SubscriptionInfo = {
...info,
id: 'other',
};
await expect(storer.handle({ info, webSocket })).resolves.toBeUndefined();
await expect(storer.handle({ info, webSocket: webSocket2 })).resolves.toBeUndefined();
await expect(storer.handle({ info: infoOther, webSocket: webSocketOther })).resolves.toBeUndefined();
// `info` expired, `infoOther` did not
storage.get.mockImplementation((id): any => {
if (id === infoOther.id) {
return infoOther;
}
});
jest.advanceTimersToNextTimer();
await flushPromises();
expect(webSocket.close).toHaveBeenCalledTimes(1);
expect(webSocket2.close).toHaveBeenCalledTimes(1);
expect(webSocketOther.close).toHaveBeenCalledTimes(0);
jest.useRealTimers();
});
});

View File

@ -0,0 +1,69 @@
import { AccessMode } from '../../../../../src/authorization/permissions/Permissions';
import {
AbsolutePathInteractionRoute,
} from '../../../../../src/identity/interaction/routing/AbsolutePathInteractionRoute';
import type { Subscription } from '../../../../../src/server/notifications/Subscription';
import type { SubscriptionStorage } from '../../../../../src/server/notifications/SubscriptionStorage';
import {
WebSocketSubscription2021,
} from '../../../../../src/server/notifications/WebSocketSubscription2021/WebSocketSubscription2021';
import { IdentifierSetMultiMap } from '../../../../../src/util/map/IdentifierMap';
import { readJsonStream } from '../../../../../src/util/StreamUtil';
describe('A WebSocketSubscription2021', (): void => {
let subscription: Subscription;
let storage: jest.Mocked<SubscriptionStorage>;
const route = new AbsolutePathInteractionRoute('http://example.com/foo');
let subscriptionType: WebSocketSubscription2021;
beforeEach(async(): Promise<void> => {
subscription = {
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
type: 'WebSocketSubscription2021',
topic: 'https://storage.example/resource',
state: undefined,
expiration: undefined,
accept: undefined,
rate: undefined,
};
storage = {
create: jest.fn().mockReturnValue({
id: '123',
topic: 'http://example.com/foo',
type: 'WebSocketSubscription2021',
lastEmit: 0,
features: {},
}),
add: jest.fn(),
} as any;
subscriptionType = new WebSocketSubscription2021(storage, route);
});
it('has the correct type.', async(): Promise<void> => {
expect(subscriptionType.type).toBe('WebSocketSubscription2021');
});
it('correctly parses subscriptions.', async(): Promise<void> => {
await expect(subscriptionType.schema.isValid(subscription)).resolves.toBe(true);
subscription.type = 'something else';
await expect(subscriptionType.schema.isValid(subscription)).resolves.toBe(false);
});
it('requires Read permissions on the topic.', async(): Promise<void> => {
await expect(subscriptionType.extractModes(subscription)).resolves
.toEqual(new IdentifierSetMultiMap([[{ path: subscription.topic }, AccessMode.read ]]));
});
it('stores the info and returns a valid response when subscribing.', async(): Promise<void> => {
const { response } = await subscriptionType.subscribe(subscription);
expect(response.metadata.contentType).toBe('application/ld+json');
await expect(readJsonStream(response.data)).resolves.toEqual({
'@context': [ 'https://www.w3.org/ns/solid/notification/v1' ],
type: 'WebSocketSubscription2021',
source: expect.stringMatching(/^ws:\/\/example.com\/foo\?auth=.+/u),
});
});
});

View File

@ -29,6 +29,7 @@ const portNames = [
'SetupMemory',
'SparqlStorage',
'Subdomains',
'WebSocketSubscription2021',
// Unit
'BaseServerFactory',