diff --git a/package-lock.json b/package-lock.json index 16c2545..7f8b7ff 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,27 @@ "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/access-control-policy": { "version": "0.1.3", "dev": true, @@ -8693,7 +8723,6 @@ }, "node_modules/@types/readable-stream": { "version": "2.3.15", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -8702,7 +8731,6 @@ }, "node_modules/@types/readable-stream/node_modules/safe-buffer": { "version": "5.1.2", - "dev": true, "license": "MIT" }, "node_modules/@types/resolve": { @@ -18004,7 +18032,6 @@ }, "node_modules/jsonld-streaming-parser": { "version": "3.3.0", - "dev": true, "license": "MIT", "dependencies": { "@bergos/jsonparse": "^1.4.0", @@ -18021,7 +18048,6 @@ }, "node_modules/jsonld-streaming-parser/node_modules/buffer": { "version": "6.0.3", - "dev": true, "funding": [ { "type": "github", @@ -18044,12 +18070,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 +28596,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,8 +29618,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" }, "devDependencies": { "@inrupt/solid-client-authn-core": "^2.2.6", diff --git a/packages/solid/package.json b/packages/solid/package.json index 20e8c64..2600356 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -44,8 +44,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/readResource.ts b/packages/solid/src/requester/requests/readResource.ts index a0a961f..940f48f 100644 --- a/packages/solid/src/requester/requests/readResource.ts +++ b/packages/solid/src/requester/requests/readResource.ts @@ -99,9 +99,13 @@ export async function readResource( try { const fetch = guaranteeFetch(options?.fetch); // Fetch options to determine the document type + console.log("Will make fetch"); + console.log(uri); const response = await fetch(uri, { headers: { accept: "text/turtle, */*" }, }); + console.log("Lets just confirm its this fetch"); + console.log(response); if (response.status === 404) { return { isError: false, @@ -128,7 +132,9 @@ export async function readResource( if (contentType.startsWith("text/turtle")) { // Parse Turtle + console.log("Before text"); const rawTurtle = await response.text(); + console.log("After Text"); if (options?.dataset) { const result = await addRawTurtleToDataset( rawTurtle, @@ -166,6 +172,7 @@ export async function readResource( }; } } catch (err) { + console.log("We're in this error", err); return UnexpectedResourceError.fromThrown(uri, err); } } diff --git a/packages/solid/src/resource/Resource.ts b/packages/solid/src/resource/Resource.ts index e52d8d1..8121b47 100644 --- a/packages/solid/src/resource/Resource.ts +++ b/packages/solid/src/resource/Resource.ts @@ -33,6 +33,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 +51,7 @@ export type SharedStatuses = Unfetched | DeleteResult | CreateSuccess; */ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{ update: () => void; + notification: () => void; }>) { /** * @internal @@ -96,6 +104,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 +285,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 +304,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 +315,26 @@ 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 { + // TODO + throw new Error("Not Implemented"); + } + /** * =========================================================================== * HELPER METHODS @@ -685,4 +719,49 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{ this.wacRule = result.wacRule; return result; } + + /** + * =========================================================================== + * SUBSCRIPTION METHODS + * =========================================================================== + */ + + /** + * TODO + */ + async subscribeToNotifications(): Promise { + this.notificationSubscription = new Websocket2023NotificationSubscription( + this, + this.onNotification.bind(this), + this.context, + ); + return await this.notificationSubscription.open(); + } + + /** + * @internal + * TODO + */ + protected async onNotification(message: NotificationMessage): Promise { + switch (message.type) { + case "Update": + const readResult = await this.read(); + console.log(readResult); + } + } + + /** + * TODO + */ + 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..34a8042 --- /dev/null +++ b/packages/solid/src/resource/notifications/NotificationMessage.ts @@ -0,0 +1,7 @@ +export interface NotificationMessage { + "@context": string | string[]; + id: string; + type: "Update"; + 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..d1c060d --- /dev/null +++ b/packages/solid/src/resource/notifications/NotificationSubscription.ts @@ -0,0 +1,35 @@ +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; + +export abstract class NotificationSubscription { + protected resource: Resource; + protected onNotification: (message: NotificationMessage) => void; + protected context: SolidLdoDatasetContext; + + constructor( + resource: Resource, + onNotification: (message: NotificationMessage) => void, + context: SolidLdoDatasetContext, + ) { + this.resource = resource; + this.onNotification = onNotification; + this.context = context; + } + + abstract open(): Promise; + 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..e62bec7 --- /dev/null +++ b/packages/solid/src/resource/notifications/Websocket2023NotificationSubscription.ts @@ -0,0 +1,61 @@ +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"; + +const CHANNEL_TYPE = + "http://www.w3.org/ns/solid/notifications#WebSocketChannel2023"; + +export class Websocket2023NotificationSubscription extends NotificationSubscription { + private socket: WebSocket | undefined; + + async open(): Promise { + const client = new SubscriptionClient(this.context.fetch); + try { + const notificationChannel = await client.subscribe( + this.resource.uri, + CHANNEL_TYPE, + ); + return new Promise((resolve) => { + this.socket = new WebSocket(notificationChannel.receiveFrom); + 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) => + resolve(UnexpectedResourceError.fromThrown(this.resource.uri, err)); + this.socket.onopen = () => { + resolve({ + isError: false, + type: "subscribeToNotificationSuccess", + uri: this.resource.uri, + }); + }; + }); + } 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 close(): Promise { + this.socket?.terminate(); + return { + type: "unsubscribeFromNotificationSuccess", + isError: false, + uri: this.resource.uri, + }; + } +} 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..7bd7b10 --- /dev/null +++ b/packages/solid/src/resource/notifications/results/NotificationErrors.ts @@ -0,0 +1,5 @@ +import { ResourceError } from "../../../requester/results/error/ErrorResult"; + +export class UnsupportedNotificationError extends ResourceError { + readonly type = "unexpectedResourceError" 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..cc876e7 100644 --- a/packages/solid/test/Integration.test.ts +++ b/packages/solid/test/Integration.test.ts @@ -42,6 +42,7 @@ 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"; const TEST_CONTAINER_SLUG = "test_ldo/"; const TEST_CONTAINER_URI = @@ -177,14 +178,16 @@ describe("Integration", () => { beforeEach(async () => { fetchMock = jest.fn(authFetch); solidLdoDataset = createSolidLdoDataset({ fetch: fetchMock }); + console.log(ROOT_CONTAINER, TEST_CONTAINER_SLUG); // Create a new document called sample.ttl - await authFetch(ROOT_CONTAINER, { + const result = await authFetch(ROOT_CONTAINER, { method: "POST", headers: { link: '; rel="type"', slug: TEST_CONTAINER_SLUG, }, }); + console.log("Create Result", result); await authFetch(TEST_CONTAINER_ACL_URI, { method: "PUT", headers: { @@ -2010,4 +2013,71 @@ describe("Integration", () => { expect(wacResult.type).toBe("serverError"); }); }); + + /** + * =========================================================================== + * NOTIFICATION SUBSCRIPTIONS + * =========================================================================== + */ + describe("Notification Subscriptions", () => { + it("Notification is propogated when a resource is updated", async () => { + expect(true).toBe(true); + const spidermanNode = namedNode("http://example.org/#spiderman"); + const foafNameNode = namedNode("http://xmlns.com/foaf/0.1/name"); + + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + await resource.read(); + + const spidermanCallback = jest.fn(); + solidLdoDataset.addListener([spidermanNode, null, null, null], jest.fn()); + + // const subscriptionResult = await resource.subscribeToNotifications(); + // expect(subscriptionResult.type).toBe("subscribeToNotificationSuccess"); + + await authFetch(SAMPLE_DATA_URI, { + method: "PATCH", + body: 'INSERT DATA { "Peter Parker" . }', + headers: { + "Content-Type": "application/sparql-update", + }, + }); + await wait(1000); + process.env.AFTER = "TRUE"; + + await resource.read(); + + expect(spidermanCallback).toHaveBeenCalledTimes(1); + expect( + solidLdoDataset.match( + spidermanNode, + foafNameNode, + literal("Peter Parker"), + ).size, + ).toBe(1); + + // // Notification is not propogated after unsubscribe + // spidermanCallback.mockClear(); + // const unsubscribeResponse = await resource.unsubscribeFromNotifications(); + // expect(unsubscribeResponse.type).toBe( + // "unsubscribeFromNotificationSuccess", + // ); + // 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); + }); + }); });