-
Notifications
You must be signed in to change notification settings - Fork 125
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Implement the Solid WebSocket protocol.
- Loading branch information
1 parent
97d7e46
commit 0099d1d
Showing
3 changed files
with
317 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,145 @@ | ||
import { EventEmitter } from 'events'; | ||
import type WebSocket from 'ws'; | ||
import { getLoggerFor } from '../logging/LogUtil'; | ||
import type { HttpRequest } from '../server/HttpRequest'; | ||
import { WebSocketHandler } from '../server/WebSocketHandler'; | ||
import type { ResourceIdentifier } from './representation/ResourceIdentifier'; | ||
|
||
const VERSION = 'solid/0.1.0-alpha'; | ||
|
||
/** | ||
* Implementation of Solid WebSockets API Spec solid/0.1.0-alpha | ||
* at https://github.com/solid/solid-spec/blob/master/api-websockets.md | ||
*/ | ||
class WebSocketListener extends EventEmitter { | ||
private host = ''; | ||
private protocol = ''; | ||
private readonly socket: WebSocket; | ||
private readonly subscribedPaths = new Set<string>(); | ||
private readonly logger = getLoggerFor(this); | ||
|
||
public constructor(socket: WebSocket) { | ||
super(); | ||
this.socket = socket; | ||
socket.addListener('error', (): void => this.stop()); | ||
socket.addListener('close', (): void => this.stop()); | ||
socket.addListener('message', (message: string): void => this.onMessage(message)); | ||
} | ||
|
||
public start(upgradeRequest: HttpRequest): void { | ||
// Greet the client | ||
this.sendMessage('protocol', VERSION); | ||
this.sendMessage('warning', 'Unstandardized protocol version, proceed with care'); | ||
|
||
// Verify the WebSocket protocol version | ||
const protocolHeader = upgradeRequest.headers['sec-websocket-protocol']; | ||
if (!protocolHeader) { | ||
this.sendMessage('warning', `Missing Sec-WebSocket-Protocol header, expected value '${VERSION}'`); | ||
} else { | ||
const supportedProtocols = protocolHeader.split(/\s*,\s*/u); | ||
if (!supportedProtocols.includes(VERSION)) { | ||
this.sendMessage('error', `Client does not support protocol ${VERSION}`); | ||
this.stop(); | ||
} | ||
} | ||
|
||
// Store the HTTP host and protocol | ||
this.host = upgradeRequest.headers.host ?? ''; | ||
this.protocol = (upgradeRequest.socket as any).secure ? 'https:' : 'http:'; | ||
} | ||
|
||
private stop(): void { | ||
try { | ||
this.socket.close(); | ||
} catch { | ||
// Ignore | ||
} | ||
this.subscribedPaths.clear(); | ||
this.socket.removeAllListeners(); | ||
this.emit('closed'); | ||
} | ||
|
||
public onResourceChanged({ path }: ResourceIdentifier): void { | ||
if (this.subscribedPaths.has(path)) { | ||
this.sendMessage('pub', path); | ||
} | ||
} | ||
|
||
private onMessage(message: string): void { | ||
// Parse the message | ||
const match = /^(\w+)\s+(.+)$/u.exec(message); | ||
if (!match) { | ||
this.sendMessage('warning', `Unrecognized message format: ${message}`); | ||
return; | ||
} | ||
|
||
// Process the message | ||
const [ , type, value ] = match; | ||
switch (type) { | ||
case 'sub': | ||
this.subscribe(value); | ||
break; | ||
default: | ||
this.sendMessage('warning', `Unrecognized message type: ${type}`); | ||
} | ||
} | ||
|
||
private subscribe(path: string): void { | ||
try { | ||
// Resolve and verify the URL | ||
const resolved = new URL(path, `${this.protocol}${this.host}`); | ||
if (resolved.host !== this.host) { | ||
throw new Error(`Mismatched host: ${resolved.host} instead of ${this.host}`); | ||
} | ||
if (resolved.protocol !== this.protocol) { | ||
throw new Error(`Mismatched protocol: ${resolved.protocol} instead of ${this.protocol}`); | ||
} | ||
// Subscribe to the URL | ||
const url = resolved.toString(); | ||
this.subscribedPaths.add(url); | ||
this.sendMessage('ack', url); | ||
this.logger.debug(`WebSocket subscribed to changes on ${url}`); | ||
} catch (error: unknown) { | ||
// Report errors to the socket | ||
const errorText: string = (error as any).message; | ||
this.sendMessage('error', errorText); | ||
this.logger.warn(`WebSocket could not subscribe to ${path}: ${errorText}`); | ||
} | ||
} | ||
|
||
private sendMessage(type: string, value: string): void { | ||
this.socket.send(`${type} ${value}`); | ||
} | ||
} | ||
|
||
/** | ||
* Provides live update functionality following | ||
* the Solid WebSockets API Spec solid/0.1.0-alpha | ||
*/ | ||
export class UnsecureWebSocketsProtocol extends WebSocketHandler { | ||
private readonly logger = getLoggerFor(this); | ||
private readonly listeners = new Set<WebSocketListener>(); | ||
|
||
public constructor(source: EventEmitter) { | ||
super(); | ||
source.on('changed', (changed: ResourceIdentifier): void => this.onResourceChanged(changed)); | ||
} | ||
|
||
public async handle(input: { webSocket: WebSocket; upgradeRequest: HttpRequest }): Promise<void> { | ||
const listener = new WebSocketListener(input.webSocket); | ||
this.listeners.add(listener); | ||
this.logger.info(`New WebSocket added, ${this.listeners.size} in total`); | ||
|
||
listener.on('closed', (): void => { | ||
this.listeners.delete(listener); | ||
this.logger.info(`WebSocket closed, ${this.listeners.size} remaining`); | ||
}); | ||
listener.start(input.upgradeRequest); | ||
} | ||
|
||
private onResourceChanged(changed: ResourceIdentifier): void { | ||
for (const listener of this.listeners) { | ||
listener.onResourceChanged(changed); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,171 @@ | ||
import { EventEmitter } from 'events'; | ||
import { UnsecureWebSocketsProtocol } from '../../../src/ldp/UnsecureWebSocketsProtocol'; | ||
import type { HttpRequest } from '../../../src/server/HttpRequest'; | ||
|
||
class DummySocket extends EventEmitter { | ||
public readonly messages = new Array<string>(); | ||
public readonly close = jest.fn(); | ||
|
||
public send(message: string): void { | ||
this.messages.push(message); | ||
} | ||
} | ||
|
||
describe('An UnsecureWebSocketsProtocol', (): void => { | ||
const source = new EventEmitter(); | ||
const protocol = new UnsecureWebSocketsProtocol(source); | ||
|
||
describe('after registering a socket', (): void => { | ||
const webSocket = new DummySocket(); | ||
|
||
beforeAll(async(): Promise<void> => { | ||
const upgradeRequest = { | ||
headers: { | ||
host: 'mypod.example', | ||
'sec-websocket-protocol': 'solid/0.1.0-alpha, other/1.0.0', | ||
}, | ||
socket: { | ||
secure: true, | ||
}, | ||
} as any as HttpRequest; | ||
await protocol.handle({ webSocket, upgradeRequest } as any); | ||
}); | ||
|
||
afterEach((): void => { | ||
webSocket.messages.length = 0; | ||
}); | ||
|
||
it('sends a protocol message.', (): void => { | ||
expect(webSocket.messages).toHaveLength(2); | ||
expect(webSocket.messages.shift()).toBe('protocol solid/0.1.0-alpha'); | ||
expect(webSocket.messages.shift()).toBe('warning Unstandardized protocol version, proceed with care'); | ||
}); | ||
|
||
it('warns when receiving an unexpected message.', (): void => { | ||
webSocket.emit('message', 'unexpected'); | ||
expect(webSocket.messages).toHaveLength(1); | ||
expect(webSocket.messages.shift()).toBe('warning Unrecognized message format: unexpected'); | ||
}); | ||
|
||
it('warns when receiving an unexpected message type.', (): void => { | ||
webSocket.emit('message', 'unknown 1 2 3'); | ||
expect(webSocket.messages).toHaveLength(1); | ||
expect(webSocket.messages.shift()).toBe('warning Unrecognized message type: unknown'); | ||
}); | ||
|
||
describe('before subscribing to resources', (): void => { | ||
it('does not emit pub messages.', (): void => { | ||
source.emit('changed', { path: 'https://mypod.example/foo/bar' }); | ||
expect(webSocket.messages).toHaveLength(0); | ||
}); | ||
}); | ||
|
||
describe('after subscribing to a resource', (): void => { | ||
beforeAll((): void => { | ||
webSocket.emit('message', 'sub https://mypod.example/foo/bar'); | ||
}); | ||
|
||
it('sends an ack message.', (): void => { | ||
expect(webSocket.messages).toHaveLength(1); | ||
expect(webSocket.messages.shift()).toBe('ack https://mypod.example/foo/bar'); | ||
}); | ||
|
||
it('emits pub messages for that resource.', (): void => { | ||
source.emit('changed', { path: 'https://mypod.example/foo/bar' }); | ||
expect(webSocket.messages).toHaveLength(1); | ||
expect(webSocket.messages.shift()).toBe('pub https://mypod.example/foo/bar'); | ||
}); | ||
}); | ||
|
||
describe('after subscribing to a resource via a relative URL', (): void => { | ||
beforeAll((): void => { | ||
webSocket.emit('message', 'sub /relative/foo'); | ||
}); | ||
|
||
it('sends an ack message.', (): void => { | ||
expect(webSocket.messages).toHaveLength(1); | ||
expect(webSocket.messages.shift()).toBe('ack https://mypod.example/relative/foo'); | ||
}); | ||
|
||
it('emits pub messages for that resource.', (): void => { | ||
source.emit('changed', { path: 'https://mypod.example/relative/foo' }); | ||
expect(webSocket.messages).toHaveLength(1); | ||
expect(webSocket.messages.shift()).toBe('pub https://mypod.example/relative/foo'); | ||
}); | ||
}); | ||
|
||
describe('after subscribing to a resource with the wrong host name', (): void => { | ||
beforeAll((): void => { | ||
webSocket.emit('message', 'sub https://wrong.example/host/foo'); | ||
}); | ||
|
||
it('send an error message.', (): void => { | ||
expect(webSocket.messages).toHaveLength(1); | ||
expect(webSocket.messages.shift()) | ||
.toBe('error Mismatched host: wrong.example instead of mypod.example'); | ||
}); | ||
}); | ||
|
||
describe('after subscribing to a resource with the wrong protocol', (): void => { | ||
beforeAll((): void => { | ||
webSocket.emit('message', 'sub http://mypod.example/protocol/foo'); | ||
}); | ||
|
||
it('send an error message.', (): void => { | ||
expect(webSocket.messages).toHaveLength(1); | ||
expect(webSocket.messages.shift()) | ||
.toBe('error Mismatched protocol: http: instead of https:'); | ||
}); | ||
}); | ||
}); | ||
|
||
it('unsubscribes when a socket closes.', async(): Promise<void> => { | ||
const newSocket = new DummySocket(); | ||
await protocol.handle({ webSocket: newSocket, upgradeRequest: { headers: {}, socket: {}}} as any); | ||
expect(newSocket.listenerCount('message')).toBe(1); | ||
newSocket.emit('close'); | ||
expect(newSocket.listenerCount('message')).toBe(0); | ||
expect(newSocket.listenerCount('close')).toBe(0); | ||
expect(newSocket.listenerCount('error')).toBe(0); | ||
}); | ||
|
||
it('unsubscribes when a socket errors.', async(): Promise<void> => { | ||
const newSocket = new DummySocket(); | ||
await protocol.handle({ webSocket: newSocket, upgradeRequest: { headers: {}, socket: {}}} as any); | ||
expect(newSocket.listenerCount('message')).toBe(1); | ||
newSocket.emit('error'); | ||
expect(newSocket.listenerCount('message')).toBe(0); | ||
expect(newSocket.listenerCount('close')).toBe(0); | ||
expect(newSocket.listenerCount('error')).toBe(0); | ||
}); | ||
|
||
it('emits a warning when no Sec-WebSocket-Protocol is supplied.', async(): Promise<void> => { | ||
const newSocket = new DummySocket(); | ||
const upgradeRequest = { | ||
headers: {}, | ||
socket: {}, | ||
} as any as HttpRequest; | ||
await protocol.handle({ webSocket: newSocket, upgradeRequest } as any); | ||
expect(newSocket.messages).toHaveLength(3); | ||
expect(newSocket.messages.pop()) | ||
.toBe('warning Missing Sec-WebSocket-Protocol header, expected value \'solid/0.1.0-alpha\''); | ||
expect(newSocket.close).toHaveBeenCalledTimes(0); | ||
}); | ||
|
||
it('emits an error and closes the connection with the wrong Sec-WebSocket-Protocol.', async(): Promise<void> => { | ||
const newSocket = new DummySocket(); | ||
const upgradeRequest = { | ||
headers: { | ||
'sec-websocket-protocol': 'solid/1.0.0, other', | ||
}, | ||
socket: {}, | ||
} as any as HttpRequest; | ||
await protocol.handle({ webSocket: newSocket, upgradeRequest } as any); | ||
expect(newSocket.messages).toHaveLength(3); | ||
expect(newSocket.messages.pop()).toBe('error Client does not support protocol solid/0.1.0-alpha'); | ||
expect(newSocket.close).toHaveBeenCalledTimes(1); | ||
expect(newSocket.listenerCount('message')).toBe(0); | ||
expect(newSocket.listenerCount('close')).toBe(0); | ||
expect(newSocket.listenerCount('error')).toBe(0); | ||
}); | ||
}); |