diff --git a/package-lock.json b/package-lock.json index 16c2545..549b9d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/solid/.gitignore b/packages/solid/.gitignore new file mode 100644 index 0000000..6320cd2 --- /dev/null +++ b/packages/solid/.gitignore @@ -0,0 +1 @@ +data \ No newline at end of file diff --git a/packages/solid/package.json b/packages/solid/package.json index 20e8c64..1717157 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -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", diff --git a/packages/solid/src/requester/requests/deleteResource.ts b/packages/solid/src/requester/requests/deleteResource.ts index d3de47e..cc8f084 100644 --- a/packages/solid/src/requester/requests/deleteResource.ts +++ b/packages/solid/src/requester/requests/deleteResource.ts @@ -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, +): void { + dataset.deleteMatches(undefined, undefined, undefined, namedNode(uri)); + deleteResourceRdfFromContainer(uri, dataset); +} diff --git a/packages/solid/src/requester/requests/readResource.ts b/packages/solid/src/requester/requests/readResource.ts index a0a961f..4b9a7bc 100644 --- a/packages/solid/src/requester/requests/readResource.ts +++ b/packages/solid/src/requester/requests/readResource.ts @@ -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", diff --git a/packages/solid/src/resource/Leaf.ts b/packages/solid/src/resource/Leaf.ts index 1490a0b..7272f15 100644 --- a/packages/solid/src/resource/Leaf.ts +++ b/packages/solid/src/resource/Leaf.ts @@ -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; } diff --git a/packages/solid/src/resource/Resource.ts b/packages/solid/src/resource/Resource.ts index e52d8d1..5810428 100644 --- a/packages/solid/src/resource/Resource.ts +++ b/packages/solid/src/resource/Resource.ts @@ -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 { + 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 { + 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 { + const result = await this.notificationSubscription?.close(); + this.notificationSubscription = undefined; + return ( + result ?? { + type: "unsubscribeFromNotificationSuccess", + isError: false, + uri: this.uri, + } + ); + } } diff --git a/packages/solid/src/resource/notifications/NotificationMessage.ts b/packages/solid/src/resource/notifications/NotificationMessage.ts new file mode 100644 index 0000000..14e5c2d --- /dev/null +++ b/packages/solid/src/resource/notifications/NotificationMessage.ts @@ -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; +} diff --git a/packages/solid/src/resource/notifications/NotificationSubscription.ts b/packages/solid/src/resource/notifications/NotificationSubscription.ts new file mode 100644 index 0000000..87ede6d --- /dev/null +++ b/packages/solid/src/resource/notifications/NotificationSubscription.ts @@ -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; + + /** + * @internal + * Closes the subscription + */ + abstract close(): Promise; +} diff --git a/packages/solid/src/resource/notifications/Websocket2023NotificationSubscription.ts b/packages/solid/src/resource/notifications/Websocket2023NotificationSubscription.ts new file mode 100644 index 0000000..5434b7b --- /dev/null +++ b/packages/solid/src/resource/notifications/Websocket2023NotificationSubscription.ts @@ -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 { + 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 { + const client = new SubscriptionClient(this.context.fetch); + return await client.subscribe( + this.resource.uri, + CHANNEL_TYPE as ChannelType, + ); + } + + async subscribeToWebsocket( + notificationChannel: NotificationChannel, + ): Promise { + return new Promise((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 { + this.socket?.terminate(); + return { + type: "unsubscribeFromNotificationSuccess", + isError: false, + uri: this.resource.uri, + }; + } +} + +function createWebsocketDefault(address: string) { + return new WebSocket(address); +} diff --git a/packages/solid/src/resource/notifications/results/NotificationErrors.ts b/packages/solid/src/resource/notifications/results/NotificationErrors.ts new file mode 100644 index 0000000..70382f7 --- /dev/null +++ b/packages/solid/src/resource/notifications/results/NotificationErrors.ts @@ -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; +} diff --git a/packages/solid/src/resource/notifications/results/SubscribeToNotificationSuccess.ts b/packages/solid/src/resource/notifications/results/SubscribeToNotificationSuccess.ts new file mode 100644 index 0000000..fd3cfcb --- /dev/null +++ b/packages/solid/src/resource/notifications/results/SubscribeToNotificationSuccess.ts @@ -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"; +} diff --git a/packages/solid/src/resource/notifications/results/UnsubscribeFromNotificationSuccess.ts b/packages/solid/src/resource/notifications/results/UnsubscribeFromNotificationSuccess.ts new file mode 100644 index 0000000..5aa97e7 --- /dev/null +++ b/packages/solid/src/resource/notifications/results/UnsubscribeFromNotificationSuccess.ts @@ -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"; +} diff --git a/packages/solid/test/Integration.test.ts b/packages/solid/test/Integration.test.ts index d9cd71d..22b60e7 100644 --- a/packages/solid/test/Integration.test.ts +++ b/packages/solid/test/Integration.test.ts @@ -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 { "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 { "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"); + }); + }); }); diff --git a/packages/solid/test/Websocket2023NotificationSubscription.test.ts b/packages/solid/test/Websocket2023NotificationSubscription.test.ts new file mode 100644 index 0000000..ddf92bb --- /dev/null +++ b/packages/solid/test/Websocket2023NotificationSubscription.test.ts @@ -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"); + }); +}); diff --git a/packages/solid/test/configs/server-config-without-websocket.json b/packages/solid/test/configs/server-config-without-websocket.json new file mode 100644 index 0000000..626d082 --- /dev/null +++ b/packages/solid/test/configs/server-config-without-websocket.json @@ -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." + ] + } + ] +} \ No newline at end of file diff --git a/packages/solid/test/solidServer.helper.ts b/packages/solid/test/solidServer.helper.ts index f16b6cc..b27b58b 100644 --- a/packages/solid/test/solidServer.helper.ts +++ b/packages/solid/test/solidServer.helper.ts @@ -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 { +export async function createApp(customConfigPath?: string): Promise { if (process.env.SERVER) { return { start: () => {}, @@ -28,12 +28,13 @@ export async function createApp(): Promise { 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", }, }); }