Merge pull request #65 from o-development/feat/notifications

Feat/notifications
main
jaxoncreed 8 months ago committed by GitHub
commit ae27348ff9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 56
      package-lock.json
  2. 1
      packages/solid/.gitignore
  3. 5
      packages/solid/package.json
  4. 28
      packages/solid/src/requester/requests/deleteResource.ts
  5. 11
      packages/solid/src/requester/requests/readResource.ts
  6. 3
      packages/solid/src/resource/Leaf.ts
  7. 143
      packages/solid/src/resource/Resource.ts
  8. 10
      packages/solid/src/resource/notifications/NotificationMessage.ts
  9. 51
      packages/solid/src/resource/notifications/NotificationSubscription.ts
  10. 102
      packages/solid/src/resource/notifications/Websocket2023NotificationSubscription.ts
  11. 9
      packages/solid/src/resource/notifications/results/NotificationErrors.ts
  12. 8
      packages/solid/src/resource/notifications/results/SubscribeToNotificationSuccess.ts
  13. 8
      packages/solid/src/resource/notifications/results/UnsubscribeFromNotificationSuccess.ts
  14. 216
      packages/solid/test/Integration.test.ts
  15. 57
      packages/solid/test/Websocket2023NotificationSubscription.test.ts
  16. 44
      packages/solid/test/configs/server-config-without-websocket.json
  17. 5
      packages/solid/test/solidServer.helper.ts

56
package-lock.json generated

@ -1948,7 +1948,6 @@
},
"node_modules/@bergos/jsonparse": {
"version": "1.4.1",
"dev": true,
"engines": [
"node >= 0.2.0"
],
@ -1959,7 +1958,6 @@
},
"node_modules/@bergos/jsonparse/node_modules/buffer": {
"version": "6.0.3",
"dev": true,
"funding": [
{
"type": "github",
@ -5872,6 +5870,17 @@
"node": ">=8"
}
},
"node_modules/@janeirodigital/interop-utils": {
"version": "1.0.0-rc.24",
"resolved": "https://registry.npmjs.org/@janeirodigital/interop-utils/-/interop-utils-1.0.0-rc.24.tgz",
"integrity": "sha512-mLOhitq6SyRSZi1DxrzTTgms7Mt0zgx/5KezkkyMBH3OYuYJBGPH6A93iBJl0wA5Ln90A9KnyiC7I/7+IUYhoQ==",
"license": "MIT",
"dependencies": {
"http-link-header": "^1.1.1",
"jsonld-streaming-parser": "^3.2.1",
"n3": "^1.17.1"
}
},
"node_modules/@jest/console": {
"version": "27.5.1",
"license": "MIT",
@ -7539,6 +7548,34 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@solid-notifications/discovery": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@solid-notifications/discovery/-/discovery-0.1.2.tgz",
"integrity": "sha512-jkqV+Ceknw2XE0Vl/4O2BBFnkCZQhNDVt6B9nzbVD4T3aNhMlK/gZS6oNHqa23obgFNCtgFBmeeRKiN1/v8lcw==",
"license": "MIT",
"dependencies": {
"@janeirodigital/interop-utils": "^1.0.0-rc.24",
"n3": "^1.17.2"
}
},
"node_modules/@solid-notifications/subscription": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@solid-notifications/subscription/-/subscription-0.1.2.tgz",
"integrity": "sha512-XnnqNsLOIdUAzB11aROzfRiJLHJjTOaHMSrnn3teQRtE0BwpbnAJtzGG/m3JNUR+QqyjKkB3jfibxJjzvI/HQg==",
"license": "MIT",
"dependencies": {
"@janeirodigital/interop-utils": "^1.0.0-rc.24",
"@solid-notifications/discovery": "^0.1.2",
"n3": "^1.17.2"
}
},
"node_modules/@solid-notifications/types": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@solid-notifications/types/-/types-0.1.2.tgz",
"integrity": "sha512-0SP6XmOjFhqt/m4FFXnYh6slSiXMoheO3UpU7POSDStLSb6tLVAQLiy0hBKvNyGBLlftRObHWoBWlt2X/LhVRg==",
"dev": true,
"license": "MIT"
},
"node_modules/@solid/access-control-policy": {
"version": "0.1.3",
"dev": true,
@ -8693,7 +8730,6 @@
},
"node_modules/@types/readable-stream": {
"version": "2.3.15",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
@ -8702,7 +8738,6 @@
},
"node_modules/@types/readable-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"dev": true,
"license": "MIT"
},
"node_modules/@types/resolve": {
@ -18004,7 +18039,6 @@
},
"node_modules/jsonld-streaming-parser": {
"version": "3.3.0",
"dev": true,
"license": "MIT",
"dependencies": {
"@bergos/jsonparse": "^1.4.0",
@ -18021,7 +18055,6 @@
},
"node_modules/jsonld-streaming-parser/node_modules/buffer": {
"version": "6.0.3",
"dev": true,
"funding": [
{
"type": "github",
@ -18044,12 +18077,10 @@
},
"node_modules/jsonld-streaming-parser/node_modules/canonicalize": {
"version": "1.0.8",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/jsonld-streaming-parser/node_modules/readable-stream": {
"version": "4.5.2",
"dev": true,
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
@ -28572,7 +28603,9 @@
}
},
"node_modules/ws": {
"version": "8.16.0",
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@ -29592,14 +29625,17 @@
"@ldo/dataset": "^0.0.1-alpha.24",
"@ldo/ldo": "^0.0.1-alpha.28",
"@ldo/rdf-utils": "^0.0.1-alpha.24",
"@solid-notifications/subscription": "^0.1.2",
"cross-fetch": "^3.1.6",
"http-link-header": "^1.1.1"
"http-link-header": "^1.1.1",
"ws": "^8.18.0"
},
"devDependencies": {
"@inrupt/solid-client-authn-core": "^2.2.6",
"@ldo/cli": "^0.0.1-alpha.28",
"@rdfjs/data-model": "^1.2.0",
"@rdfjs/types": "^1.0.1",
"@solid-notifications/types": "^0.1.2",
"@solid/community-server": "^7.1.3",
"@types/jest": "^27.0.3",
"cross-env": "^7.0.3",

@ -0,0 +1 @@
data

@ -29,6 +29,7 @@
"@ldo/cli": "^0.0.1-alpha.28",
"@rdfjs/data-model": "^1.2.0",
"@rdfjs/types": "^1.0.1",
"@solid-notifications/types": "^0.1.2",
"@solid/community-server": "^7.1.3",
"@types/jest": "^27.0.3",
"cross-env": "^7.0.3",
@ -44,8 +45,10 @@
"@ldo/dataset": "^0.0.1-alpha.24",
"@ldo/ldo": "^0.0.1-alpha.28",
"@ldo/rdf-utils": "^0.0.1-alpha.24",
"@solid-notifications/subscription": "^0.1.2",
"cross-fetch": "^3.1.6",
"http-link-header": "^1.1.1"
"http-link-header": "^1.1.1",
"ws": "^8.18.0"
},
"files": [
"dist",

@ -7,6 +7,8 @@ import { UnexpectedHttpError } from "../results/error/HttpErrorResult";
import { HttpErrorResult } from "../results/error/HttpErrorResult";
import type { DeleteSuccess } from "../results/success/DeleteSuccess";
import type { DatasetRequestOptions } from "./requestOptions";
import type { IBulkEditableDataset } from "@ldo/subscribable-dataset";
import type { Quad } from "@rdfjs/types";
/**
* All possible return values for deleteResource
@ -62,15 +64,8 @@ export async function deleteResource(
// if it hasn't been deleted when you're unauthenticated. 404 happens when
// the document never existed
if (response.status === 205 || response.status === 404) {
if (options?.dataset) {
options.dataset.deleteMatches(
undefined,
undefined,
undefined,
namedNode(uri),
);
deleteResourceRdfFromContainer(uri, options.dataset);
}
if (options?.dataset)
updateDatasetOnSuccessfulDelete(uri, options.dataset);
return {
isError: false,
type: "deleteSuccess",
@ -83,3 +78,18 @@ export async function deleteResource(
return UnexpectedResourceError.fromThrown(uri, err);
}
}
/**
* Assuming a successful delete has just been performed, this function updates
* datastores to reflect that.
*
* @param uri - The uri of the resouce that was removed
* @param dataset - The dataset that should be updated
*/
export function updateDatasetOnSuccessfulDelete(
uri: string,
dataset: IBulkEditableDataset<Quad>,
): void {
dataset.deleteMatches(undefined, undefined, undefined, namedNode(uri));
deleteResourceRdfFromContainer(uri, dataset);
}

@ -20,6 +20,7 @@ import { NoncompliantPodError } from "../results/error/NoncompliantPodError";
import { guaranteeFetch } from "../../util/guaranteeFetch";
import { UnexpectedResourceError } from "../results/error/ErrorResult";
import { checkHeadersForRootContainer } from "./checkRootContainer";
import { namedNode } from "@rdfjs/data-model";
/**
* All possible return values for reading a leaf
@ -103,6 +104,16 @@ export async function readResource(
headers: { accept: "text/turtle, */*" },
});
if (response.status === 404) {
// Clear existing data if present
if (options?.dataset) {
options.dataset.deleteMatches(
undefined,
undefined,
undefined,
namedNode(uri),
);
}
return {
isError: false,
type: "absentReadSuccess",

@ -362,7 +362,8 @@ export class Leaf extends Resource {
* A helper method updates this leaf's internal state upon delete success
* @param result - the result of the delete success
*/
protected updateWithDeleteSuccess(_result: DeleteSuccess) {
public updateWithDeleteSuccess(result: DeleteSuccess) {
super.updateWithDeleteSuccess(result);
this.binaryData = undefined;
}

@ -15,7 +15,10 @@ import type TypedEmitter from "typed-emitter";
import EventEmitter from "events";
import { getParentUri } from "../util/rdfUtils";
import type { RequesterResult } from "../requester/results/RequesterResult";
import type { DeleteResult } from "../requester/requests/deleteResource";
import {
updateDatasetOnSuccessfulDelete,
type DeleteResult,
} from "../requester/requests/deleteResource";
import type { ReadSuccess } from "../requester/results/success/ReadSuccess";
import { isReadSuccess } from "../requester/results/success/ReadSuccess";
import type { DeleteSuccess } from "../requester/results/success/DeleteSuccess";
@ -33,6 +36,13 @@ import { NoncompliantPodError } from "../requester/results/error/NoncompliantPod
import { setWacRuleForAclUri, type SetWacRuleResult } from "./wac/setWacRule";
import type { LeafUri } from "../util/uriTypes";
import type { NoRootContainerError } from "../requester/results/error/NoRootContainerError";
import type {
CloseSubscriptionResult,
NotificationSubscription,
OpenSubscriptionResult,
} from "./notifications/NotificationSubscription";
import { Websocket2023NotificationSubscription } from "./notifications/Websocket2023NotificationSubscription";
import type { NotificationMessage } from "./notifications/NotificationMessage";
/**
* Statuses shared between both Leaf and Container
@ -44,6 +54,7 @@ export type SharedStatuses = Unfetched | DeleteResult | CreateSuccess;
*/
export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{
update: () => void;
notification: () => void;
}>) {
/**
* @internal
@ -96,6 +107,12 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{
*/
protected wacRule?: WacRule;
/**
* @internal
* Handles notification subscriptions
*/
protected notificationSubscription?: NotificationSubscription;
/**
* @param context - SolidLdoDatasetContext for the parent dataset
*/
@ -271,7 +288,7 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{
* ```typescript
* // Logs "undefined"
* console.log(resource.isAbsent());
* const result = resource.read();
* const result = await resource.read();
* if (!result.isError) {
* // False if the resource exists, true if it does not
* console.log(resource.isAbsent());
@ -290,7 +307,7 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{
* ```typescript
* // Logs "undefined"
* console.log(resource.isPresent());
* const result = resource.read();
* const result = await resource.read();
* if (!result.isError) {
* // True if the resource exists, false if it does not
* console.log(resource.isPresent());
@ -301,6 +318,25 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{
return this.absent === undefined ? undefined : !this.absent;
}
/**
* Is this resource currently listening to notifications from this document
* @returns true if the resource is subscribed to notifications, false if not
*
* @example
* ```typescript
* // Logs "undefined"
* console.log(resource.isPresent());
* const result = resource.read();
* if (!result.isError) {
* // True if the resource exists, false if it does not
* console.log(resource.isPresent());
* }
* ```
*/
isSubscribedToNotifications(): boolean {
return !!this.notificationSubscription;
}
/**
* ===========================================================================
* HELPER METHODS
@ -393,7 +429,7 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{
* A helper method updates this resource's internal state upon delete success
* @param result - the result of the delete success
*/
protected updateWithDeleteSuccess(_result: DeleteSuccess) {
public updateWithDeleteSuccess(_result: DeleteSuccess) {
this.absent = true;
this.didInitialFetch = true;
}
@ -685,4 +721,103 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{
this.wacRule = result.wacRule;
return result;
}
/**
* ===========================================================================
* SUBSCRIPTION METHODS
* ===========================================================================
*/
/**
* Activates Websocket subscriptions on this resource. Updates, deletions,
* and creations on this resource will be tracked and all changes will be
* relected in LDO's resources and graph.
*
* @param onNotificationError - A callback function if there is an error
* with notifications.
* @returns OpenSubscriptionResult
*
* @example
* ```typescript
* const resource = solidLdoDataset
* .getResource("https://example.com/spiderman");
* // A listener for if anything about spiderman in the global dataset is
* // changed. Note that this will also listen for any local changes as well
* // as changes to remote resources to which you have notification
* // subscriptions enabled.
* solidLdoDataset.addListener(
* [namedNode("https://example.com/spiderman#spiderman"), null, null, null],
* () => {
* // Triggers when the file changes on the Pod or locally
* console.log("Something changed about SpiderMan");
* },
* );
*
* // Subscribe
* const subscriptionResult = await testContainer.subscribeToNotifications();
* // ... From there you can ait for a file to be changed on the Pod.
*/
async subscribeToNotifications(
onNotificationError?: (err: Error) => void,
): Promise<OpenSubscriptionResult> {
this.notificationSubscription = new Websocket2023NotificationSubscription(
this,
this.onNotification.bind(this),
onNotificationError,
this.context,
);
return await this.notificationSubscription.open();
}
/**
* @internal
* Function that triggers whenever a notification is recieved.
*/
protected async onNotification(message: NotificationMessage): Promise<void> {
const objectResource = this.context.solidLdoDataset.getResource(
message.object,
);
switch (message.type) {
case "Update":
case "Add":
await objectResource.read();
return;
case "Delete":
case "Remove":
// Delete the resource without have to make an additional read request
updateDatasetOnSuccessfulDelete(
message.object,
this.context.solidLdoDataset,
);
objectResource.updateWithDeleteSuccess({
type: "deleteSuccess",
isError: false,
uri: message.object,
resourceExisted: true,
});
return;
}
}
/**
* Unsubscribes from changes made to this resource on the Pod
*
* @returns CloseSubscriptionResult
*
* @example
* ```typescript
* resource.unsubscribeFromNotifications()
* ```
*/
async unsubscribeFromNotifications(): Promise<CloseSubscriptionResult> {
const result = await this.notificationSubscription?.close();
this.notificationSubscription = undefined;
return (
result ?? {
type: "unsubscribeFromNotificationSuccess",
isError: false,
uri: this.uri,
}
);
}
}

@ -0,0 +1,10 @@
/**
* A message sent from the Pod as a notification
*/
export interface NotificationMessage {
"@context": string | string[];
id: string;
type: "Update" | "Delete" | "Remove" | "Add";
object: string;
published: string;
}

@ -0,0 +1,51 @@
import type { UnexpectedResourceError } from "../../requester/results/error/ErrorResult";
import type { SolidLdoDatasetContext } from "../../SolidLdoDatasetContext";
import type { Resource } from "../Resource";
import type { NotificationMessage } from "./NotificationMessage";
import type { UnsupportedNotificationError } from "./results/NotificationErrors";
import type { SubscribeToNotificationSuccess } from "./results/SubscribeToNotificationSuccess";
import type { UnsubscribeToNotificationSuccess } from "./results/UnsubscribeFromNotificationSuccess";
export type OpenSubscriptionResult =
| SubscribeToNotificationSuccess
| UnsupportedNotificationError
| UnexpectedResourceError;
export type CloseSubscriptionResult =
| UnsubscribeToNotificationSuccess
| UnexpectedResourceError;
/**
* @internal
* Abstract class for notification subscription methods.
*/
export abstract class NotificationSubscription {
protected resource: Resource;
protected onNotification: (message: NotificationMessage) => void;
protected onError?: (err: Error) => void;
protected context: SolidLdoDatasetContext;
constructor(
resource: Resource,
onNotification: (message: NotificationMessage) => void,
onError: ((err: Error) => void) | undefined,
context: SolidLdoDatasetContext,
) {
this.resource = resource;
this.onNotification = onNotification;
this.onError = onError;
this.context = context;
}
/**
* @internal
* Opens the subscription
*/
abstract open(): Promise<OpenSubscriptionResult>;
/**
* @internal
* Closes the subscription
*/
abstract close(): Promise<CloseSubscriptionResult>;
}

@ -0,0 +1,102 @@
import { UnexpectedResourceError } from "../../requester/results/error/ErrorResult";
import type {
CloseSubscriptionResult,
OpenSubscriptionResult,
} from "./NotificationSubscription";
import { NotificationSubscription } from "./NotificationSubscription";
import { SubscriptionClient } from "@solid-notifications/subscription";
import { WebSocket } from "ws";
import { UnsupportedNotificationError } from "./results/NotificationErrors";
import type { NotificationMessage } from "./NotificationMessage";
import type { Resource } from "../Resource";
import type { SolidLdoDatasetContext } from "../../SolidLdoDatasetContext";
import type {
ChannelType,
NotificationChannel,
} from "@solid-notifications/types";
const CHANNEL_TYPE =
"http://www.w3.org/ns/solid/notifications#WebSocketChannel2023";
export class Websocket2023NotificationSubscription extends NotificationSubscription {
private socket: WebSocket | undefined;
private createWebsocket: (address: string) => WebSocket;
constructor(
resource: Resource,
onNotification: (message: NotificationMessage) => void,
onError: ((err: Error) => void) | undefined,
context: SolidLdoDatasetContext,
createWebsocket?: (address: string) => WebSocket,
) {
super(resource, onNotification, onError, context);
this.createWebsocket = createWebsocket ?? createWebsocketDefault;
}
async open(): Promise<OpenSubscriptionResult> {
try {
const notificationChannel = await this.discoverNotificationChannel();
return this.subscribeToWebsocket(notificationChannel);
} catch (err) {
if (
err instanceof Error &&
err.message.startsWith("Discovery did not succeed")
) {
return new UnsupportedNotificationError(this.resource.uri, err.message);
}
return UnexpectedResourceError.fromThrown(this.resource.uri, err);
}
}
async discoverNotificationChannel(): Promise<NotificationChannel> {
const client = new SubscriptionClient(this.context.fetch);
return await client.subscribe(
this.resource.uri,
CHANNEL_TYPE as ChannelType,
);
}
async subscribeToWebsocket(
notificationChannel: NotificationChannel,
): Promise<OpenSubscriptionResult> {
return new Promise<OpenSubscriptionResult>((resolve) => {
let didResolve = false;
this.socket = this.createWebsocket(
notificationChannel.receiveFrom as string,
);
this.socket.onmessage = (message) => {
const messageData = message.data.toString();
// TODO uncompliant Pod error on misformatted message
this.onNotification(JSON.parse(messageData) as NotificationMessage);
};
this.socket.onerror = (err) => {
if (!didResolve) {
resolve(UnexpectedResourceError.fromThrown(this.resource.uri, err));
} else {
this.onError?.(err.error);
}
};
this.socket.onopen = () => {
didResolve = true;
resolve({
isError: false,
type: "subscribeToNotificationSuccess",
uri: this.resource.uri,
});
};
});
}
async close(): Promise<CloseSubscriptionResult> {
this.socket?.terminate();
return {
type: "unsubscribeFromNotificationSuccess",
isError: false,
uri: this.resource.uri,
};
}
}
function createWebsocketDefault(address: string) {
return new WebSocket(address);
}

@ -0,0 +1,9 @@
import { ResourceError } from "../../../requester/results/error/ErrorResult";
/**
* Indicates that the requested method for receiving notifications is not
* supported by this Pod.
*/
export class UnsupportedNotificationError extends ResourceError {
readonly type = "unsupportedNotificationError" as const;
}

@ -0,0 +1,8 @@
import type { ResourceSuccess } from "../../../requester/results/success/SuccessResult";
/**
* Returned when a notification has been successfully subscribed to for a resource
*/
export interface SubscribeToNotificationSuccess extends ResourceSuccess {
type: "subscribeToNotificationSuccess";
}

@ -0,0 +1,8 @@
import type { ResourceSuccess } from "../../../requester/results/success/SuccessResult";
/**
* Returned when a notification has been successfully unsubscribed from for a resource
*/
export interface UnsubscribeToNotificationSuccess extends ResourceSuccess {
type: "unsubscribeFromNotificationSuccess";
}

@ -42,6 +42,9 @@ import type { GetWacRuleSuccess } from "../src/resource/wac/results/GetWacRuleSu
import type { WacRule } from "../src/resource/wac/WacRule";
import type { GetStorageContainerFromWebIdSuccess } from "../src/requester/results/success/CheckRootContainerSuccess";
import { generateAuthFetch } from "./authFetch.helper";
import { wait } from "./utils.helper";
import fs from "fs/promises";
import path from "path";
const TEST_CONTAINER_SLUG = "test_ldo/";
const TEST_CONTAINER_URI =
@ -172,6 +175,8 @@ describe("Integration", () => {
app.stop();
process.env.JEST_WORKER_ID = previousJestId;
process.env.NODE_ENV = previousNodeEnv;
const testDataPath = path.join(__dirname, "../data");
await fs.rm(testDataPath, { recursive: true, force: true });
});
beforeEach(async () => {
@ -2010,4 +2015,215 @@ describe("Integration", () => {
expect(wacResult.type).toBe("serverError");
});
});
/**
* ===========================================================================
* NOTIFICATION SUBSCRIPTIONS
* ===========================================================================
*/
describe("Notification Subscriptions", () => {
const spidermanNode = namedNode("http://example.org/#spiderman");
const foafNameNode = namedNode("http://xmlns.com/foaf/0.1/name");
it("handles notification when a resource is updated", async () => {
const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI);
await resource.read();
const spidermanCallback = jest.fn();
solidLdoDataset.addListener(
[spidermanNode, null, null, null],
spidermanCallback,
);
const subscriptionResult = await resource.subscribeToNotifications();
expect(subscriptionResult.type).toBe("subscribeToNotificationSuccess");
expect(resource.isSubscribedToNotifications()).toBe(true);
await authFetch(SAMPLE_DATA_URI, {
method: "PATCH",
body: 'INSERT DATA { <http://example.org/#spiderman> <http://xmlns.com/foaf/0.1/name> "Peter Parker" . }',
headers: {
"Content-Type": "application/sparql-update",
},
});
await wait(1000);
expect(
solidLdoDataset.match(
spidermanNode,
foafNameNode,
literal("Peter Parker"),
).size,
).toBe(1);
expect(spidermanCallback).toHaveBeenCalledTimes(1);
// Notification is not propogated after unsubscribe
spidermanCallback.mockClear();
const unsubscribeResponse = await resource.unsubscribeFromNotifications();
expect(unsubscribeResponse.type).toBe(
"unsubscribeFromNotificationSuccess",
);
expect(resource.isSubscribedToNotifications()).toBe(false);
await authFetch(SAMPLE_DATA_URI, {
method: "PATCH",
body: 'INSERT DATA { <http://example.org/#spiderman> <http://xmlns.com/foaf/0.1/name> "Miles Morales" . }',
headers: {
"Content-Type": "application/sparql-update",
},
});
await wait(50);
expect(spidermanCallback).not.toHaveBeenCalled();
expect(
solidLdoDataset.match(
spidermanNode,
foafNameNode,
literal("Miles Morales"),
).size,
).toBe(0);
});
it("handles notification when subscribed to a child that is deleted", async () => {
const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI);
const testContainer = solidLdoDataset.getResource(TEST_CONTAINER_URI);
await resource.read();
const spidermanCallback = jest.fn();
solidLdoDataset.addListener(
[spidermanNode, null, null, null],
spidermanCallback,
);
const containerCallback = jest.fn();
solidLdoDataset.addListener(
[namedNode(TEST_CONTAINER_URI), null, null, null],
containerCallback,
);
const subscriptionResult = await resource.subscribeToNotifications();
expect(subscriptionResult.type).toBe("subscribeToNotificationSuccess");
await authFetch(SAMPLE_DATA_URI, {
method: "DELETE",
});
await wait(1000);
expect(solidLdoDataset.match(spidermanNode, null, null).size).toBe(0);
expect(
testContainer.children().some((child) => child.uri === SAMPLE_DATA_URI),
).toBe(false);
expect(spidermanCallback).toHaveBeenCalledTimes(1);
expect(containerCallback).toHaveBeenCalledTimes(1);
await resource.unsubscribeFromNotifications();
});
it("handles notification when subscribed to a parent with a deleted child", async () => {
const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI);
const testContainer = solidLdoDataset.getResource(TEST_CONTAINER_URI);
await resource.read();
const spidermanCallback = jest.fn();
solidLdoDataset.addListener(
[spidermanNode, null, null, null],
spidermanCallback,
);
const containerCallback = jest.fn();
solidLdoDataset.addListener(
[namedNode(TEST_CONTAINER_URI), null, null, null],
containerCallback,
);
const subscriptionResult = await testContainer.subscribeToNotifications();
expect(subscriptionResult.type).toBe("subscribeToNotificationSuccess");
await authFetch(SAMPLE_DATA_URI, {
method: "DELETE",
});
await wait(1000);
expect(solidLdoDataset.match(spidermanNode, null, null).size).toBe(0);
expect(
testContainer.children().some((child) => child.uri === SAMPLE_DATA_URI),
).toBe(false);
expect(spidermanCallback).toHaveBeenCalledTimes(1);
expect(containerCallback).toHaveBeenCalledTimes(1);
await testContainer.unsubscribeFromNotifications();
});
it("handles notification when subscribed to a parent with an added child", async () => {
const resource = solidLdoDataset.getResource(SAMPLE2_DATA_URI);
const testContainer = solidLdoDataset.getResource(TEST_CONTAINER_URI);
await resource.read();
const spidermanCallback = jest.fn();
solidLdoDataset.addListener(
[spidermanNode, null, null, null],
spidermanCallback,
);
const containerCallback = jest.fn();
solidLdoDataset.addListener(
[namedNode(TEST_CONTAINER_URI), null, null, null],
containerCallback,
);
const subscriptionResult = await testContainer.subscribeToNotifications();
expect(subscriptionResult.type).toBe("subscribeToNotificationSuccess");
await authFetch(TEST_CONTAINER_URI, {
method: "POST",
headers: { "content-type": "text/turtle", slug: "sample2.ttl" },
body: SPIDER_MAN_TTL,
});
await wait(1000);
expect(solidLdoDataset.match(spidermanNode, null, null).size).toBe(4);
expect(
testContainer
.children()
.some((child) => child.uri === SAMPLE2_DATA_URI),
).toBe(true);
expect(spidermanCallback).toHaveBeenCalledTimes(1);
expect(containerCallback).toHaveBeenCalledTimes(1);
await testContainer.unsubscribeFromNotifications();
});
it("returns an error when it cannot subscribe to a notification", async () => {
const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI);
await app.stop();
const subscriptionResult = await resource.subscribeToNotifications();
expect(subscriptionResult.type).toBe("unexpectedResourceError");
await app.start();
});
it("returns an error when the server doesnt support websockets", async () => {
const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI);
await app.stop();
const disabledWebsocketsApp = await createApp(
path.join(__dirname, "./configs/server-config-without-websocket.json"),
);
await disabledWebsocketsApp.start();
const subscriptionResult = await resource.subscribeToNotifications();
expect(subscriptionResult.type).toBe("unsupportedNotificationError");
await disabledWebsocketsApp.stop();
await app.start();
});
it("causes no problems when unsubscribing when not subscribed", async () => {
const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI);
const result = await resource.unsubscribeFromNotifications();
expect(result.type).toBe("unsubscribeFromNotificationSuccess");
});
});
});

@ -0,0 +1,57 @@
import type { WebSocket, Event, ErrorEvent } from "ws";
import { Websocket2023NotificationSubscription } from "../src/resource/notifications/Websocket2023NotificationSubscription";
import type { SolidLdoDatasetContext } from "../src";
import { Leaf } from "../src";
import type { NotificationChannel } from "@solid-notifications/types";
describe("Websocket2023NotificationSubscription", () => {
it("returns an error when websockets have an error", async () => {
const WebSocketMock: WebSocket = {} as WebSocket;
const onErrorMock = jest.fn();
const subscription = new Websocket2023NotificationSubscription(
new Leaf("https://example.com", {
fetch,
} as unknown as SolidLdoDatasetContext),
() => {},
onErrorMock,
{} as unknown as SolidLdoDatasetContext,
() => WebSocketMock,
);
const subPromise = subscription.subscribeToWebsocket({
receiveFrom: "http://example.com",
} as unknown as NotificationChannel);
WebSocketMock.onopen?.({} as Event);
const subscriptionResult = await subPromise;
expect(subscriptionResult.type).toBe("subscribeToNotificationSuccess");
WebSocketMock.onerror?.({ error: new Error("Test Error") } as ErrorEvent);
expect(onErrorMock).toHaveBeenCalled();
});
it("returns an error when websockets have an error at the beginning", async () => {
const WebSocketMock: WebSocket = {} as WebSocket;
const onErrorMock = jest.fn();
const subscription = new Websocket2023NotificationSubscription(
new Leaf("https://example.com", {
fetch,
} as unknown as SolidLdoDatasetContext),
() => {},
onErrorMock,
{} as unknown as SolidLdoDatasetContext,
() => WebSocketMock,
);
const subPromise = subscription.subscribeToWebsocket({
receiveFrom: "http://example.com",
} as unknown as NotificationChannel);
WebSocketMock.onerror?.({ error: new Error("Test Error") } as ErrorEvent);
const subscriptionResult = await subPromise;
expect(subscriptionResult.type).toBe("unexpectedResourceError");
});
});

@ -0,0 +1,44 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^7.0.0/components/context.jsonld",
"import": [
"css:config/app/init/initialize-root.json",
"css:config/app/main/default.json",
"css:config/app/variables/default.json",
"css:config/http/handler/default.json",
"css:config/http/middleware/default.json",
"css:config/http/notifications/webhooks.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/no-accounts.json",
"css:config/identity/oidc/default.json",
"css:config/identity/ownership/token.json",
"css:config/identity/pod/static.json",
"css:config/ldp/authentication/dpop-bearer.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/backend/file.json",
"css:config/storage/key-value/resource-store.json",
"css:config/storage/location/root.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/resource-locker/file.json",
"css:config/util/variables/default.json"
],
"@graph": [
{
"comment": [
"A Solid server that stores its resources on disk and uses WAC for authorization.",
"No registration and the root container is initialized to allow full access for everyone so make sure to change this."
]
}
]
}

@ -14,7 +14,7 @@ export const WEB_ID =
// Use an increased timeout, since the CSS server takes too much setup time.
jest.setTimeout(40_000);
export async function createApp(): Promise<App> {
export async function createApp(customConfigPath?: string): Promise<App> {
if (process.env.SERVER) {
return {
start: () => {},
@ -28,12 +28,13 @@ export async function createApp(): Promise<App> {
mainModulePath: resolveModulePath(""),
typeChecking: false,
},
config: resolveModulePath("config/default.json"),
config: customConfigPath ?? resolveModulePath("config/file-root.json"),
variableBindings: {},
shorthand: {
port: 3_001,
loggingLevel: "off",
seedConfig: path.join(__dirname, "configs", "solid-css-seed.json"),
rootFilePath: "./data",
},
});
}

Loading…
Cancel
Save