commit
ae27348ff9
@ -0,0 +1 @@ |
|||||||
|
data |
@ -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"; |
||||||
|
} |
@ -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." |
||||||
|
] |
||||||
|
} |
||||||
|
] |
||||||
|
} |
Loading…
Reference in new issue