@achingbrain/ssdp
Version:
Yet another SSDP implementation for node.js
544 lines (507 loc) • 16.1 kB
text/typescript
/**
* @packageDocumentation
*
* First, import the module, call the function and set up an error handler:
*
* ```javascript
* import ssdp from '@achingbrain/ssdp'
*
* const bus = await ssdp()
*
* // print error messages to the console
* bus.on('error', console.error)
* ```
*
* @example Find a service
*
* Pass a `serviceType` to the `discover` method - when services are found events will be emitted:
*
* ```javascript
* // this is the unique service name we are interested in:
* const serviceType = 'urn:schemas-upnp-org:service:ContentDirectory:1'
*
* for await (const service of bus.discover({ serviceType })) {
* // search for instances of a specific service
* }
*
* bus.on('service:discover', service => {
* // receive a notification about discovery of a service
* })
*
* bus.on('service:update', service => {
* // receive a notification when that service is updated - nb. this will only happen
* // after the service max-age is reached and if the service's device description
* // document has changed
* })
* ```
*
* @example Find all services
*
* Don't pass any options to the `discover` method (n.b. you will also receive protocol related events):
*
* ```javascript
* for await (const service of bus.discover()) {
* // receive a notification about all service types
* }
* ```
*
* @example Advertise a service
*
* ```javascript
* // advertise a service
*
* const advert = await bus.advertise({
* usn: 'urn:schemas-upnp-org:service:ContentDirectory:1',
* details: {
* URLBase: 'https://192.168.0.1:8001'
* }
* })
*
* // stop advertising a service
* await advert.stop()
* ```
*
* For full options, see the [Advertisement](https://achingbrain.github.io/ssdp/interfaces/Advertisement.html) interface.
*
* ## Integrate with existing HTTP servers
*
* By default when you create an advertisement an HTTP server is created to serve the `details.xml` document that describes your service. To use an existing server instead, do something like:
*
* @example Hapi
*
* ```javascript
* const advert = await bus.advertise({
* usn: 'urn:schemas-upnp-org:service:ContentDirectory:1',
* location: {
* udp4: 'http://192.168.0.1:8000/ssdp/details.xml'
* },
* details: {
* URLBase: 'https://192.168.0.1:8001'
* }
* })
*
* server.route({
* method: 'GET',
* path: '/ssdp/details.xml',
* handler: (request, reply) => {
* reply(advert.service.details())
* .type('text/xml')
* }
* })
* ```
*
* @example Express
*
* ```javascript
* const advert = await bus.advertise({
* usn: 'urn:schemas-upnp-org:service:ContentDirectory:1',
* location: {
* udp4: 'http://192.168.0.1:8000/ssdp/details.xml'
* },
* details: {
* URLBase: 'https://192.168.0.1:8001'
* }
* })
*
* app.get('/ssdp/details.xml', async (request, response) => {
* response.set('Content-Type', 'text/xml')
*
* try {
* const details = await advert.service.details()
* response.send(details)
* } catch (err) {
* response.set('Content-Type', 'text/xml')
* response.send(err)
* }
* })
* ```
*
* @example Shutting down gracefully
*
* `ssdp` opens several ports to communicate with other devices on your network, to shut them down, do something like:
*
* ```javascript
* process.on('SIGINT',() => {
* // stop the server(s) from running - this will also send ssdp:byebye messages for all
* // advertised services however they'll only have been sent once the callback is
* // invoked so it won't work with process.on('exit') as you can only perform synchronous
* // operations there
* bus.stop(error => {
* process.exit(error ? 1 : 0)
* })
* })
* ```
*
* ## Full API and options
*
* ```javascript
* import ssdp from '@achingbrain/ssdp'
*
* // all arguments are optional
* var bus = ssdp({
* udn: 'unique-identifier', // defaults to a random UUID
* // a string to identify the server by
* signature: 'node.js/0.12.6 UPnP/1.1 @achingbrain/ssdp/1.0.0',
* retry {
* times: 5, // how many times to attempt joining the UDP multicast group
* interval: 5000 // how long to wait between attempts
* },
* // specify one or more sockets to listen on
* sockets: [{
* type: 'udp4', // or 'udp6'
* broadcast: {
* address: '239.255.255.250', // or 'FF02::C'
* port: 1900 // SSDP broadcast port
* },
* bind: {
* address: '0.0.0.0', // or '0:0:0:0:0:0:0:0'
* port: 1900
* },
* maxHops: 4 // how many network segments packets are allow to travel through (UDP TTL)
* }]
* })
* bus.on('error', console.error)
*
* // this is the type of service we are interested in
* var serviceType = 'urn:schemas-upnp-org:service:ContentDirectory:1'
*
* // search for one type of service
* for await (const service of bus.discover({ serviceType })) {
*
* }
*
* bus.on('service:discover', service => {
* // receive a notification when a service of the passed type is discovered
* })
*
* bus.on('service:update', service => {
* // receive a notification when that service is updated
* })
*
* // search for all types of service
* for await (const service of bus.discover()) {
*
* }
*
* // advertise a service
* const advert = await bus.advertise({
* usn: 'a-usn', // unique service name
* interval: 10000, // how often to broadcast service adverts in ms
* ttl: 1800000, // how long the advert is valid for in ms
* ipv4: true, // whether or not to broadcast the advert over IPv4
* ipv6: true, // whether or not to broadcast the advert over IPv6
* location: { // where the description document(s) are available - omit to have an http server automatically created
* udp4: 'http://192.168.0.1/details.xml', // where the description document is available over ipv4
* udp6: 'http://FE80::0202:B3FF:FE1E:8329/details.xml' // where the description document is available over ipv6
* },
* details: { // the contents of the description document
* specVersion: {
* major: 1,
* minor: 1
* },
* URLBase: 'http://example.com',
* device: {
* deviceType: 'a-usn',
* friendlyName: 'A friendly device name',
* manufacturer: 'Manufactuer name',
* manufacturerURL: 'http://example.com',
* modelDescription: 'A description of the device',
* modelName: 'A model name',
* modelNumber: 'A vendor specific model number',
* modelURL: 'http://example.com',
* serialNumber: 'A device specific serial number',
* UDN: 'unique-identifier' // should be the same as the bus USN
* presentationURL: 'index.html'
* }
* }
* })
*
* // stop advertising a service
* advert.stop()
* ```
*
* ## Device description document
*
* During UPnP device discovery, clients can request a [description of the various capabilities your service offers](http://jan.newmarch.name/internetdevices/upnp/upnp-devices.html).
* To do this you can either store an xml document and set the `location` field of your advert to point at that document
* or have it automatically generated.
*
* E.g., create a document, `description.xml` and put it on a server at `http://server.com/path/to/description.xml`:
*
* ```xml
* <root xmlns="urn:schemas-upnp-org:device-1-0">
* <specVersion>
* <major>1</major>
* <minor>0</minor>
* </specVersion>
* <URLBase>http://192.168.1.41:80</URLBase>
* <device>
* <deviceType>urn:schemas-upnp-org:device:Basic:1</deviceType>
* <friendlyName>I am a light controller</friendlyName>
* <manufacturer>Royal Philips Electronics</manufacturer>
* <manufacturerURL>http://www.philips.com</manufacturerURL>
* <modelDescription>Philips hue Personal Wireless Lighting</modelDescription>
* <modelName>Philips hue bridge 2012</modelName>
* <modelNumber>23409823049823</modelNumber>
* <modelURL>http://www.meethue.com</modelURL>
* <serialNumber>asd09f8s90832</serialNumber>
* <UDN>uuid:2f402f80-da50-12321-9b23-2131298129</UDN>
* <presentationURL>index.html</presentationURL>
* </device>
* </root>
* ```
*
* Then create your advert:
*
* ```javascript
* bus.advertise({
* usn: 'urn:schemas-upnp-org:device:Basic:1',
* location: {
* udp4: 'http://192.168.1.40/path/to/description.xml'
* }
* })
* ```
*
* Alternatively provide an descriptor object and let this module do the heavy lifting (n.b.
* your object will be run through the [xml2js Builder](https://libraries.io/npm/xml2js#user-content-xml-builder-usage)):
*
* ```javascript
* bus.advertise({
* usn: 'urn:schemas-upnp-org:device:Basic:1',
* details: {
* '$': {
* 'xmlns': 'urn:schemas-upnp-org:device-1-0'
* },
* 'specVersion': {
* 'major': '1',
* 'minor': '0'
* },
* 'URLBase': 'http://192.168.1.41:80',
* 'device': {
* 'deviceType': 'urn:schemas-upnp-org:device:Basic:1',
* 'friendlyName': 'I am a light controller',
* 'manufacturer': 'Royal Philips Electronics',
* 'manufacturerURL': 'http://www.philips.com',
* 'modelDescription': 'Philips hue Personal Wireless Lighting',
* 'modelName': 'Philips hue bridge 2012',
* 'modelNumber': '23409823049823',
* 'modelURL': 'http://www.meethue.com',
* 'serialNumber': 'asd09f8s90832',
* 'UDN': 'uuid:2f402f80-da50-12321-9b23-2131298129',
* 'presentationURL': 'index.html'
* }
* }
* })
* ```
*
* A random high port will be chosen, a http server will listen on that port and serve the descriptor and the `LOCATION`
* header will be set appropriately in all `ssdp` messages.
*
* The server will be shut down when you call `advert.stop`.
*
* ## I want to see all protocol messages
*
* No problem, try this:
*
* ```javascript
* bus.on('transport:outgoing-message', (socket, message, remote) => {
* console.info('-> Outgoing to %s:%s via %s', remote.address, remote.port, socket.type)
* console.info(message.toString('utf8'))
* })
* bus.on('transport:incoming-message', (message, remote) => {
* console.info('<- Incoming from %s:%s', remote.address, remote.port)
* console.info(message.toString('utf8'))
* })
* ```
*
* Alternatively see [test/fixtures/all.ts](https://github.com/achingbrain/ssdp/blob/main/test/fixtures/all.ts)
*
* ## References
*
* - [LG SSDP discovery documentation](http://developer.lgappstv.com/TV_HELP/topic/lge.tvsdk.references.book/html/UDAP/UDAP/Discovery.htm)
* - [UPnP overview](http://jan.newmarch.name/internetdevices/upnp/upnp.html)
* - [UPnP device description](http://jan.newmarch.name/internetdevices/upnp/upnp-devices.html)
* - [UPnP Device Architecture v1.1](http://upnp.org/specs/arch/UPnP-arch-DeviceArchitecture-v1.1.pdf)
* - [diversario/node-ssdp](https://github.com/diversario/node-ssdp)
* - [Xedecimal/node-ssdp](https://www.npmjs.com/package/ssdp) (no longer maintained)
*/
import { SSDP as SSDPImpl } from './ssdp.js'
import type { CachedAdvert } from './adverts.js'
import type { AbortOptions } from 'abort-error'
import type { Socket } from 'dgram'
export type { CachedAdvert } from './adverts.js'
export type { Advert } from './advertise/index.js'
export interface NetworkAddress {
address: string
port: number
}
export interface SSDPSocketOptions {
type?: 'udp4' | 'udp6'
broadcast?: NetworkAddress
bind?: NetworkAddress
maxHops?: number
}
export interface SSDPOptions {
/**
* The unique device name for the device advertised by this instance.
*
* Not required if nothing is being advertised.
*
* @default undefined
*/
udn?: string
/**
* The human-readable description of the device advertised by this instance.
*
* Not required if nothing is being advertised.
*
* @default undefined
*/
signature?: string
/**
* The UDP sockets to listen on/broadcast to
*/
sockets?: SSDPSocketOptions[]
/**
* Whether or not to create ports and set up event listeners
*
* @default true
*/
start?: boolean
/**
* Whether to cache discovered services using their Unique Device Names.
*
* @default true
*/
cache?: boolean
}
export interface SSDPSocket extends Socket {
type: 'udp4' | 'udp6'
closed: boolean
options: SSDPSocketOptions
}
export interface NotifyMessage {
LOCATION: string
USN: string
NT: string
NTS: 'ssdp:alive' | 'ssdp:byebye'
ttl(): number
}
export interface SearchMessage {
LOCATION: string
USN: string
ST: string
ttl(): number
}
export interface SSDPEvents {
'transport:incoming-message'(buffer: Buffer, from: NetworkAddress): void
'transport:outgoing-message'(socket: SSDPSocket, buffer: Buffer, to: NetworkAddress): void
'ssdp:send-message'(status: string, headers: Record<string, any>, to?: NetworkAddress): void
'ssdp:m-search'(message: SearchMessage, from: NetworkAddress): void
'ssdp:notify'(message: NotifyMessage, from: NetworkAddress): void
'ssdp:search-response'(message: SearchMessage, from: NetworkAddress): void
'service:discover'(service: Service): void
'service:update'(service: Service): void
'service:remove'(usn: string): void
'error'(err: Error): void
}
export interface Service<DeviceDescription = Record<string, any>> {
location: URL
details: DeviceDescription
expires: number
serviceType: string
uniqueServiceName: string
}
export interface Advertisement {
/**
* A unique service name for the advertisement
*/
usn: string
/**
* Advertisement details. Will be transformed into XML and should follow the
* urn:schemas-upnp-org:device-1-0 schema.
*/
details: Record<string, any> | (() => Promise<Record<string, any>>)
/**
* An optional amount in ms which is the frequency to advertise the service
*/
interval?: number
/**
* An optional amount in ms which is how long the advert should be cached for
*/
ttl?: number
/**
* Whether to broadcast the advert over IPv4 (default: true)
*/
ipv4?: boolean
/**
* Whether to broadcast the advert over IPv6 (default: true)
*/
ipv6?: boolean
/**
* Where the description document(s) are available - omit to have an http
* server automatically created
*/
location?: { udp4: string, udp6: string }
}
export interface DiscoverOptions extends AbortOptions {
/**
* The service type to discover
*/
serviceType?: string
/**
* By default a single `M-SEARCH` message is broadcast at the beginning of
* discovery.
*
* If devices on your network sometimes don't appear in search results, try
* passing a value here, the `M-SEARCH` message will be re-broadcast on that
* interval.
*/
searchInterval?: number
}
export interface SSDP {
/**
* Unique device name - identifies the device and must the same over time for a specific device instance
*/
udn: string
/**
* A user-agent style string to identify the implementation
*/
signature: string
/**
* Currently open sockets
*/
sockets: SSDPSocket[]
/**
* Options passed to the constructor
*/
options: SSDPOptions
start(): Promise<void>
stop(): Promise<void>
advertise(advert: Advertisement): Promise<CachedAdvert>
discover<Details = Record<string, any>>(options?: DiscoverOptions): AsyncGenerator<Service<Details>>
/**
* @deprecated Pass `DiscoverOptions` instead
*/
discover<Details = Record<string, any>>(serviceType?: string): AsyncIterable<Service<Details>>
// events
on<U extends keyof SSDPEvents>(
event: U, listener: SSDPEvents[U]
): this
off<U extends keyof SSDPEvents>(
event: U, listener: SSDPEvents[U]
): this
once<U extends keyof SSDPEvents>(
event: U, listener: SSDPEvents[U]
): this
emit<U extends keyof SSDPEvents>(
event: U, ...args: Parameters<SSDPEvents[U]>
): boolean
}
export default async function (options: SSDPOptions = {}): Promise<SSDP> {
const ssdp = new SSDPImpl(options)
if (options.start !== false) {
await ssdp.start()
}
return ssdp
}